Wiring our app — which workloads go where
A capstone map placing every async workload in the course's app at its right tier, inline await, after(), Vercel Cron, or Trigger.dev.
You now have the whole ladder: inline await for work the user waits on, after() for invisible same-invocation cleanup, Vercel Cron for schedules, and Trigger.dev for durable, multi-step, fan-out, or waitable work the first three can’t reach.
This lesson places each rung in the actual app, and asks the harder question too: where does the cheapest rung stay the right answer?
That second half is where juniors over-reach. Once you’ve learned a durable job platform, every async operation looks like it deserves one, and almost none do. There’s no new API today, only a map: every recurring or asynchronous workload in the app, sorted into its tier with the reason attached. The rule that decides every placement is the one that threaded through the chapter: code stays at the lowest tier that meets the durability, latency, and time-budget requirement.
Placing every workload by tier
Section titled “Placing every workload by tier”Seven workloads, the tier each lives at, and the one fact that fixes it. Four stay on the platform default; three earn Trigger.dev.
| Workload | Tier | Deciding reason |
|---|---|---|
Invitation email from inviteMember | inline await | one ~200 ms Resend call; the user must know it sent |
| Analytics event after checkout | after() | fire-and-log; must not roll back the DB transaction |
| Audit-log row | inside db.transaction | atomic with the mutation; never deferred |
| Hourly trial-expiry sweep | Vercel Cron | fixed UTC schedule, predicate-idempotent UPDATE, fits the function budget |
| CSV export | Trigger.dev | every condition: multi-step, paginated, past the budget, retries, final email |
| Stripe reconciliation sweep | Trigger.dev | durability; a partial reconciliation is wrong and must resume on a crash |
| Notification dispatcher fan-out | Trigger.dev | fan-out to N channels × N users, serialized per org |
Walk it once as a single sweep up the ladder.
The invitation email is one Resend call that finishes in a couple hundred milliseconds, and whoever clicked “Send invitation” needs to know it went out, so it blocks the response and runs inline.
The analytics event is the opposite: nobody waits on it, and a flaky provider must never roll back the transaction that recorded the sale, so it fires after the response with after().
The audit-log row isn’t deferred at all; it’s written inside the same db.transaction as the mutation it records, because an audit entry that can drift out of sync with what it audits is worse than none.
The hourly trial-expiry sweep runs an UPDATE flipping any expired trial to past_due on a fixed schedule, well inside the function budget; because it’s predicate-idempotent , a duplicate from Cron’s at-least-once scheduler matches nothing and changes nothing, so Cron is enough.
Those four are the load-bearing half, because each is where a junior would over-reach: “the invite could fail, make it durable,” or “the trial sweep matters, run it on Trigger.dev for the dashboard.” Both are wrong, because no condition is crossed. Escalate on a condition, never on a vibe. The last three rows cross one for real.
Three workloads that earn Trigger.dev
Section titled “Three workloads that earn Trigger.dev”Each of these trips a specific one of the five conditions, but only the first ships in this course.
CSV export, built next. A user exports their organization’s invoices. A small org is one query and a download; a large org is a paginated read of tens of thousands of rows that bursts past the function budget, where each page is a checkpoint you don’t want to redo, a transient database or Resend hiccup should retry rather than fail the job, and the finished file goes out as an “export ready” email. That trips all five conditions at once, which is why it’s been the chapter’s canonical “yes.” The next chapter ships it end to end; here we only place it.
Stripe reconciliation sweep, a forward note.
A nightly job reads Stripe for any org whose lastEventAt is older than 24 hours and reconciles drift back into plan_entitlements, repairing a missed or out-of-order webhook.
The schedule alone is trivially a cron, but the reconciliation needs durability: if the worker crashes after 200 of 500 orgs, restarting from zero re-applies work and can re-derive entitlements from a half-applied state, and a partial reconciliation is a wrong one.
So the cron shrinks to firing the schedule and a durable run does the work, making the “cron schedules, Trigger.dev does the durable work” split concrete.
The course doesn’t build it; it’s named so you see the pattern recurs.
Notification dispatcher fan-out, a forward note.
One domain event, say “comment added,” fans out to many channels across many members of an org.
That’s fan-out by definition, and it needs per-tenant isolation: a queue keyed with concurrencyKey: organizationId serializes sends within an org so one noisy org can’t starve the rest, while staying parallel across orgs.
A later chapter builds it; here it shows that fan-out plus per-tenant isolation is a recurring reason to escalate, not a one-off.
Drill: sort the workloads
Section titled “Drill: sort the workloads”Before reading on, commit to an answer. Don’t pattern-match the table; run the test. Does the workload cross a named condition, or does the cheapest tier still cover it?
Sort each of our app's workloads into where it runs — the platform default (inline, `after()`, or Cron) or Trigger.dev. Drag each item into the bucket it belongs to, then press Check.
inviteMemberSame codebase, two runtimes
Section titled “Same codebase, two runtimes”A misconception inflates the perceived cost of Trigger.dev: that it’s a separate codebase, repo, or service you maintain on the side. It isn’t.
Your tasks live in a trigger/ directory in the same repository as your Next.js app.
They import the same lib/email.ts and lib/billing.ts your Server Actions import, use the same Drizzle schema, call the same tenantDb(organizationId), write to the same audit log, and hit the same Postgres.
The only thing that differs is where the code runs: a Server Action runs in a Vercel function, a task runs in a Trigger.dev worker.
Trigger.dev is a runtime for code that already lives in your app, not a second app.
One seam is different, and you already met it: a task has no Better Auth session and no tenant-db middleware, because there’s no request. Org context is cargo here, not ambient, so the org id rides in the task’s payload and tenancy is re-derived inside the run.
await exportCsv.trigger({ organizationId, requestedBy: user.id });The org id is cargo. The Server Action holds the session, so it reads organizationId from context and hands it to the task in the payload.
const db = tenantDb(payload.organizationId);No ambient context to lean on. With no session and no middleware, the task re-derives the tenant-scoped client from the payload, using the same tenant-db.ts and Postgres.
One off-ramp, named without dwelling: Trigger.dev v4 is Apache-2.0, so you can self-host the workers once an app outgrows the free tier or has data-residency constraints, with no change to the code pattern.
Environment variables and deploy ordering
Section titled “Environment variables and deploy ordering”Two operational facts come with this layer. Both are production hazards you need to recognize, even though their full treatment comes later.
The environment surface. Three variables make the background-work layer run. Seeing them together gives you the whole surface at once.
| Variable | Where it lives | What it’s for |
|---|---|---|
TRIGGER_SECRET_KEY | app side, server-only | lets the SDK trigger tasks over HTTPS |
TRIGGER_PROJECT_REF | trigger.config.ts | the proj_… ref tying your local code to the cloud project |
CRON_SECRET | cron route handlers | the Bearer secret you already verify on every cron invocation |
TRIGGER_SECRET_KEY must be distinct per environment: development, staging, and production each get their own.
Sharing one key is the same blast-radius mistake as sharing a webhook signing secret across environments, which you saw earlier: a leaked key then reaches every environment at once instead of being contained to one.
Deploy ordering, the one new idea in this lesson. When a deploy introduces a new task that a Server Action triggers, the order matters, and the cost stays invisible until it bites in production.
Follow the dependency direction. The app is the caller, the task is the callee. If the app ships first, there’s a window where live app code calls for a task version the workers don’t have yet. That trigger call fails at runtime, on a real user’s request, with nothing obviously wrong in your code. So the rule is: deploy the callee before the caller. Ship the task to the Trigger.dev workers first, then ship the app that references it.
trigger deploy
vercel deploy
trigger deploy
vercel deploy
In practice you won’t run two CLI commands in order by hand. Trigger.dev v4 ships atomic deployments and a first-party Vercel integration that encode this ordering for you: the Vercel deploy is gated on the task build, the matching task version is pinned, and the two go live in lockstep, so the app can never reference a mismatched version. Learn the “callee before caller” rule as the principle the automation encodes, so you understand why the sequencing matters and recognize the failure the moment automation is off or misconfigured.
One last note, on cost, where the framing matters more than any number because pricing is volatile. Trigger.dev bills per run, per run-minute, and per concurrency seat , while Vercel bills per function invocation. Don’t compare the two units directly; they measure different things, so the comparison is a category error. Watch your per-task run counts weekly. When a run count spikes, the cause is almost always a missing idempotency key or a retry storm, not real user growth.
Check your placement instinct
Section titled “Check your placement instinct”The hard half of this skill isn’t reciting the table, it’s arguing not to escalate when a placement is already right.
A teammate proposes moving the invitation email out of inviteMember and into a Trigger.dev task — “for consistency with the export job.” What’s the right call?
Where this goes next
Section titled “Where this goes next”Next you build the workload that earns Trigger.dev.
The project clones the starter and writes the export-csv task end to end: payload validation, one durable checkpoint per page with a per-page idempotency key, the final “export ready” email, and runs verified in the dashboard.
Then you kill the worker mid-run to prove the durability is real, and finish with the chapter quiz.
The canonical SDK reference. Version-volatile — confirm against the latest before copying.
The schedule tier's reference, including the at-least-once delivery guarantee.
API reference for the fire-and-log tier — scheduling work that runs after the response ships.
The Vercel integration that encodes the callee-before-caller deploy ordering this lesson teaches.
How a stable key makes a retried trigger run exactly once — the fix for run-count spikes.