Skip to content
Chapter 66Lesson 7

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.

Seven workloads, the tier each lives at, and the one fact that fixes it. Four stay on the platform default; three earn Trigger.dev.

WorkloadTierDeciding reason
Invitation email from inviteMemberinline awaitone ~200 ms Resend call; the user must know it sent
Analytics event after checkoutafter()fire-and-log; must not roll back the DB transaction
Audit-log rowinside db.transactionatomic with the mutation; never deferred
Hourly trial-expiry sweepVercel Cronfixed UTC schedule, predicate-idempotent UPDATE, fits the function budget
CSV exportTrigger.devevery condition: multi-step, paginated, past the budget, retries, final email
Stripe reconciliation sweepTrigger.devdurability; a partial reconciliation is wrong and must resume on a crash
Notification dispatcher fan-outTrigger.devfan-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.

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.

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.

Platform default inline `await`, `after()`, or Vercel Cron
Trigger.dev durable, multi-step, fan-out, or waitable
Invitation email from inviteMember
Analytics event after checkout
Audit-log row inside the transaction
Hourly trial-expiry sweep
Paginated CSV export with final email
Nightly Stripe reconciliation
Notification fan-out to N members

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.

Two runtimesShared foundationlib/ · Drizzle schema · tenant-db.ts · Postgres · audit logNext.js app on VercelServer Actions, route handlersTrigger.dev workerstasks in trigger/ trigger over HTTPS(payload carries organizationId) same tenant-db.ts,same audit logsame tenant-db.ts,same audit log
Two runtimes run the code, one Vercel function and one Trigger.dev worker, but they sit on a single shared foundation. The app triggers a task over HTTPS, handing it the org id as cargo; both read and write the same Postgres through the same tenant-scoped client.

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.

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.

VariableWhere it livesWhat it’s for
TRIGGER_SECRET_KEYapp side, server-onlylets the SDK trigger tasks over HTTPS
TRIGGER_PROJECT_REFtrigger.config.tsthe proj_… ref tying your local code to the cloud project
CRON_SECRETcron route handlersthe 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.

callee
Task version on Trigger.dev workers
the task the app will call
deploying now trigger deploy
caller
Next.js app on Vercel
Server Action that triggers the task
not live yet vercel deploy
The new task version lands on the workers first. The app isn't live yet, so nothing can reference a task that doesn't exist.
callee
Task version on Trigger.dev workers
the task the app will call
live trigger deploy
caller
Next.js app on Vercel
Server Action that triggers the task
deploying now vercel deploy
Then the app goes live. The task version is already on the workers, so every trigger call references a version that already exists.

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.

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?

Keep it where it is. The send is one sub-second call the user is actively waiting on, and matching the export’s house style isn’t one of the conditions that earns a task.
Move it — once you run a durable job platform, every async operation belongs on it.
Move it — Resend calls can fail, and a task hands you automatic retries the inline path doesn’t have.
Move it — running every background workload on a single platform is easier to reason about.

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.