Skip to content
Chapter 66Lesson 7

Wiring our app — which workloads go where

A capstone map placing every async workload in the course's SaaS at its right tier, inline await, after(), Vercel Cron, or Trigger.dev.

You now have the whole ladder. Use inline await for work the user waits on, after() for cleanup that has to run on the same invocation but stays invisible, Vercel Cron for schedules, and Trigger.dev for the durable, multi-step, fan-out, waitable work that the first three tiers can’t reach. After six lessons of building those rungs, this lesson answers the question that has been waiting at the top of the ladder the whole time: where, in our actual app, does each rung get used, and just as important, where does the cheapest rung stay the right answer?

That second half is the part juniors get wrong. Once you’ve learned a durable job platform, every async operation starts to look like it deserves one, but it almost never does. So what you walk away with today isn’t a new API, since there’s no new API in this lesson. It’s a map: every recurring or asynchronous workload in the course’s SaaS, sorted into the tier it lives at, with the reason attached to each placement. The spine of that map is the single rule that threaded through every lesson: code stays at the lowest tier that meets the durability, latency, and time-budget requirement. Today you apply it to a real application, end to end.

Here is the whole map on one screen: seven workloads, the tier each one lives at, and the single fact that decides its placement. Read it once top to bottom before we walk through it, so the shape is visible at a glance. Four of the seven stay on the platform default, and 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 |

Now walk the table as a single sweep up the ladder. The first three rows are the cheap tiers from the start of the chapter. The invitation email is one Resend call that finishes in a couple hundred milliseconds, and the person who clicked “Send invitation” needs to know it actually went out, so it blocks the response and runs inline. The analytics event after a checkout is the opposite: nobody is waiting on it, and a flaky analytics provider must never roll back the database 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 the thing it audits is worse than no audit entry.

Row four is the schedule tier. The hourly trial-expiry sweep runs an UPDATE that flips any trial past its expiry date to past_due. It’s on a fixed UTC schedule, and it finishes well inside a function’s time budget. The fact that keeps it on Cron is that it’s predicate-idempotent : the first run flips the matching rows, so a duplicate delivery from Cron’s at-least-once scheduler matches nothing and changes nothing. No dedup key, no durability machinery, no second platform. Vercel Cron is enough.

The last three rows are the escalations, and each one trips a specific one of the five conditions you learned in the lesson on when Trigger.dev earns its weight. The CSV export trips all five. The Stripe reconciliation sweep trips durability: a half-finished reconciliation leaves the billing state wrong, so the run has to resume from where it crashed rather than restart from a corrupted middle. The notification dispatcher trips fan-out , since one event explodes into N sends across N members, and it needs per-tenant serialization so one noisy org can’t starve the rest. We’ll take those three one at a time in a moment.

But notice what the table is really doing. The four “stays put” rows are not filler; they’re the load-bearing half. Every one of them is a place a junior would over-reach: “the invite send could fail, let’s make it durable,” or “the trial sweep is important, let’s run it on Trigger.dev for the dashboard.” Both are wrong. The invite send is one call under the time wall, so no condition is crossed. The trial sweep is a predicate-idempotent UPDATE that fits the budget, so again no condition is crossed. The rule from earlier in the chapter holds with no exceptions: escalate on a condition, never on a vibe. The existence of an upgrade path is not a reason to take it.

These three cross a line the platform default can’t. It’s worth being precise about which condition each one trips, and equally precise about which of them you’ll actually build, because only one ships in this course.

CSV export, built next. A user asks to export their organization’s invoices to CSV. On a small org that’s a single query and a download. On a large org it’s a paginated read of tens of thousands of rows that bursts past the function time budget, where each page is a checkpoint you don’t want to redo, where a transient database or Resend hiccup should retry rather than fail the whole job, and where the finished file goes out as an “export ready” email at the end. That trips every one of the five conditions at once: multi-step, past the time wall, retries, fan-out-adjacent pagination, and a final notification. This is exactly why it has been the chapter’s canonical “yes” since the lesson on when Trigger.dev earns its weight. The next chapter ships it end to end; this lesson only places it.

Stripe reconciliation sweep, a forward note rather than a build. A nightly job reads Stripe for any organization whose lastEventAt is older than 24 hours and reconciles any drift back into the plan_entitlements row, repairing the rare case where a webhook was missed or arrived out of order. Here’s the subtlety: this one could start on Vercel Cron, since the schedule part is trivially a cron. But the reconciliation itself needs durability. If the worker crashes after reconciling 200 of 500 orgs, restarting from zero re-applies work and risks re-deriving entitlements from a half-applied state, and a partial reconciliation is a wrong reconciliation. So the cron’s job shrinks to firing the schedule, and a durable run does the work that must resume on a crash. That makes the “cron schedules, Trigger.dev does the durable work” split concrete. This course doesn’t build it; it’s named so you see that the pattern recurs past the one export.

Notification dispatcher fan-out, a forward note for a later chapter. One domain event, say “comment added,” fans out to many channels across many members of an org: an email here, an in-app inbox entry there, multiplied across everyone who should hear about it. That’s fan-out by definition. And it needs per-tenant isolation: a queue keyed with concurrencyKey: organizationId serializes the sends within an org so one organization firing a thousand events can’t starve every other tenant’s notifications, while staying fully parallel across orgs. It’s triggered from a small notifyEvent Server Action helper, and the notify-org-members task you wrote a couple lessons ago was the warm-up shape for exactly this. A later chapter builds the real dispatcher; today it’s here only to show that fan-out plus per-tenant isolation is a recurring reason to escalate, not a one-off.

The point of these three together is that the five conditions aren’t a special case carved out for the export. They generalize. Different workloads trip different subsets, and each subset is a legitimate, defensible reason to reach past the platform default, but only once a condition is genuinely crossed.

Before you read another word, commit to an answer. Drag each of the app’s workloads into where it runs. Don’t pattern-match the table you just read; run the test instead. Does this 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

Here’s a misconception worth clearing up before you reach for Trigger.dev, because it inflates the perceived cost of doing so: Trigger.dev is not a separate codebase, repo, or service you maintain on the side.

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, they use the same Drizzle schema, they call the same tenantDb(organizationId) and write to the same audit log, and they hit the same Postgres. Nothing about the data layer is duplicated. The only thing that differs is where the code runs: a Server Action runs in a Vercel function, while a task runs in a Trigger.dev worker process. Same code, same data, two runtimes. Trigger.dev is a runtime for code that already lives in your app, not a second app.

There is exactly one seam that is different, and you already met it: a task has no Better Auth session and no tenant-db middleware wrapping the request, 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.

Seeing the two compute surfaces sit on one shared foundation is what makes “two runtimes, one codebase” click, and it’s what lowers the cost of reaching for Trigger.dev once a condition genuinely trips. The diagram below draws it: two boxes that run code on top, and one box underneath that holds everything they share.

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 worth naming, without dwelling on it: Trigger.dev v4 is Apache-2.0, so you can self-host the workers once a SaaS outgrows the free tier or has data-residency constraints. The code pattern doesn’t change, only where the workers run. The codebase stays exactly where it is; just the runtime moves.

There are two operational facts you have to leave with. Both are real production hazards, and both get the deep treatment in later units. Here you just need to know they exist and why they bite.

The environment surface. Three variables make the background-work layer run. It’s worth seeing them in one place so you hold the complete surface at once, instead of meeting them scattered across separate lessons.

| 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 is a server-only secret, and it must be distinct per environment: development, staging, and production each get their own. Sharing one key across environments is the same blast-radius mistake as sharing a webhook signing secret across environments, which you saw earlier. A leaked or misused key then reaches every environment at once instead of being contained to one. TRIGGER_PROJECT_REF is the proj_… identifier that ties the code in your repo to a specific cloud project. And CRON_SECRET is the same one from the Cron lesson, listed here only so the full surface is in front of you. These three foreshadow the next chapter’s .env.example. This is a surface to recognize, not a setup tutorial.

Deploy ordering, the one genuinely new idea in this lesson. When a deploy introduces a new task that a Server Action triggers, there’s an ordering rule, and it’s the kind of thing that stays invisible until it bites in production.

Think about the dependency direction. The app is the caller, and the task is the callee. If the app ships first, there’s a window where the new app code is live and calling 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
First, the new task version lands on the workers, so the callee exists before anything calls it. 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 that triggers the task goes live. By now the task version is already on the workers, so every trigger call it makes can only ever reference a version that already exists.

Now the 2026 reality, because running two CLI commands in the right order by hand is not how you’ll actually do this. Trigger.dev v4 ships atomic deployments and a first-party Vercel integration that encode exactly this ordering for you. With them enabled, the Vercel deploy is gated on the task build completing, the matching task version is pinned, and the two go live in lockstep, so the app can never reference a mismatched task version even if you do nothing. Treat the manual “callee before caller” rule as the principle the automation encodes. You’re learning it not so you’ll run the commands by hand, but so you understand why the sequencing matters when the platform is doing it silently on your behalf, and so you recognize the failure the moment automation is off or misconfigured. The deeper CI/CD machinery is a later unit’s job.

One last operational note, on cost, where the framing matters more than any number because pricing is volatile. Trigger.dev bills on a different unit from Vercel: per run, per run-minute, and per concurrency seat, where Vercel bills per function invocation. Watch your per-task run counts weekly, the same way you watch Vercel function invocations. When a task’s run count spikes, the cause is almost always a missing idempotency key or a retry storm, not real user growth. And resist the urge to compare Trigger.dev’s cost-per-run to Vercel’s cost-per-invocation directly; they measure different things, so the comparison is a category error.

The skill this whole chapter has been building toward isn’t reciting the table. It’s defending a placement in both directions, including the harder direction of arguing not to escalate. Try this one.

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.

That’s the chapter. Seven workloads, and one rule that placed every one of them: code stays at the lowest tier that meets the durability, latency, and time-budget requirement. Inline when the user waits. after() when nobody waits but it must run on this invocation. Cron when it’s on a clock and fits the budget. Trigger.dev, and only Trigger.dev, when a real condition forces it: durability, multi-step orchestration, retries on its own clock, fan-out, or a durable pause. Four of the seven stayed cheap, and that ratio is the lesson.

Next, you build the one that earns it. 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 that the durability you’ve been placing this whole chapter is real. After that comes the chapter quiz to check what stuck.