Skip to content
Chapter 66Lesson 3

When a workload needs a job platform

The test for when a workload earns a durable background-job platform like Trigger.dev, and when the cheap tiers win.

You can already keep work off the request path three ways: inline await for work the user waits on, after() for cleanup that runs on the same invocation but never reaches the user, and Vercel Cron for anything on a schedule. Most of what a web app does fits in these three tiers, and recognizing that is what keeps you from over-building.

Past them sits a durable background-job platform; in this course that platform is Trigger.dev, and the next three lessons teach its SDK. Before reaching for it, you need to know whether the workload earns it. This is a decision lesson with almost no code: you should leave able to argue either way at the fork, “Vercel Cron is enough, here’s why” or “Trigger.dev, because of this exact property,” and with a test you can run against any workload.

flowchart LR
  inline["<b>inline await</b><br/><i>user waits on it</i>"]
  after["<b>after()</b><br/><i>same invocation,<br/>after the response</i>"]
  cron["<b>Vercel Cron</b><br/><i>on a schedule</i>"]
  next(["<b>?</b>"])

  inline --> after --> cron --> next

  class inline,after,cron tier
  class next unknown
  classDef tier fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef unknown fill:transparent,stroke:#94a3b8,color:#94a3b8,stroke-width:2px,stroke-dasharray:5 5
Three tiers cover same-invocation and scheduled work. This lesson is about the fourth.

Each tier stays the right default until something specific forces you up to the next one. This lesson is about what that something is.

Why a durable job platform must earn its cost

Section titled “Why a durable job platform must earn its cost”

The cheap tiers already cover a large fraction of a web app’s background work, so the question is sharp: what do they still fail at, and what does a durable job platform give you that’s worth running a second platform?

That cost is real and permanent. A job platform is not a library you add to package.json and forget; it’s a second system, with its own deploy step in CI, its own dashboard to check, its own secret to rotate, and its own source of a 3 a.m. page. The capability has to be worth that standing cost.

So escalate only when a named, testable condition crosses, never on a hunch. “This feels like a background job” is not a reason; “this trips condition 3” is. There are exactly five such conditions, and we name them next.

Five conditions that justify a job platform

Section titled “Five conditions that justify a job platform”

Each condition is a property of a workload the cheap tiers can’t provide. Trip even one and the cheap tiers are out; trip none and you stay cheap. For each, hold onto two things: the workload that trips it, and how the cheap tier fails.

Condition 1 of 5

Past the function time wall

Work that needs more wall-clock time than one function invocation gets, past the 13-minute cap on Pro, 5 minutes on Hobby. The textbook case is a 50,000-row CSV export: reading, formatting, and writing that many rows can’t finish before the wall.

Condition 2 of 5

Multi-step orchestration with intermediate state

Step A, then a pause or a wait for something external, then step B, where re-running step A after a failure in step B would be wrong or expensive. Charge a card, then provision the account, then send the welcome email: if provisioning fails, the retry must not charge the card again.

Condition 3 of 5

Automatic retries with backoff

Work that must survive a transient downstream outage on its own schedule, not the user’s. A partner API or Resend returns a 503, and the right behavior is to try again in 2 seconds, then 6, then 20, until it comes back.

Condition 4 of 5

Fan-out with concurrency control

One trigger that spawns many child runs, hundreds or thousands or tens of thousands, with a cap on how many run at once. This shape is called fan-out . The canonical case is a weekly digest that has to email 50,000 users without tripping Resend’s rate limit.

Condition 5 of 5

Event-driven / human-in-the-loop pauses

Work that blocks on something outside your system: a third-party callback, a human clicking “approve,” or a wall-clock delay of hours or days. Kick off a partner video render and resume only when the partner calls back, or hold a refund until an admin approves it.

The five share one shape: work that outlives a single request, which runs once and is over. These conditions are work that takes longer than one request, survives the failure of one, multiplies into many, or waits across many.

Now run the test on real workloads. Watch for the trap: some belong on the cheap tier, and reaching for a job platform anyway is the most common real-world mistake.

Sort each workload into the condition that forces it off the cheap tiers — or into the cheap-tier bucket if none do. Drag each item into the bucket it belongs to, then press Check.

Past the time wall Exceeds the per-invocation cap
Multi-step with state Steps that must not redo each other on retry
Retries with backoff Survives a transient outage on its own schedule
Fan-out One trigger, many capped child runs
Event / human pause Blocks on a callback, approval, or long delay
Stays on the cheap tier Inline, after(), or Vercel Cron
Send one invitation email (~200 ms)
Export 80,000 invoices to a CSV file
Email a weekly digest to every user without tripping the rate limit
Wait for a partner’s render webhook before saving the result
Retry a flaky payment-provider call until it comes back
Charge the card, then provision, then email — no step repeats on retry
Nightly four-minute trial-expiry sweep
Hold a refund until an admin approves it

Conditions that do not justify a job platform

Section titled “Conditions that do not justify a job platform”

The common mistake isn’t missing a real trigger; it’s reaching for a job platform when none of the five conditions has crossed. Three cases fool people most often.

A slow API call that’s still under the time wall. Slowness alone is not a trigger. If the user doesn’t need the result, push it to after(); if they do, you’re stuck with the latency either way, since a job platform doesn’t make the call faster, it just adds a “where did my result go” problem on top. A 3-second call is annoying, not a reason to run a second platform.

A nightly job that fits the function budget. A four-minute sweep that runs once a day is exactly what Vercel Cron is for. A schedule is not a trigger by itself; you climb past Cron only when the scheduled work also trips one of the five, by being too big for one invocation, needing retries, or fanning out.

“I want a separate worker for cleanliness.” A job platform is not an aesthetic choice. Lifting a Server Action’s body into a “clean” separate worker, when the work finishes fine inside the action, buys you a second deploy, a second dashboard, and a network hop in exchange for a feeling. Separation you can’t tie to one of the five conditions is pure cost.

Keep one line for code review: escalate on a condition, never on a vibe.

A teammate opens a pull request that moves the body of inviteMember — a DB insert plus a single ~200 ms Resend call that already finishes well inside the function budget — out into a Trigger.dev task. Their PR description reads: “Keeps the Server Action thin and puts the email logic in its own file.” You’re the reviewer. What’s the right call?

Request changes — nothing here trips one of the five, so the move pays a second platform’s standing cost to buy a tidier file. If the action feels crowded, lift the email into a plain helper and keep the work inline.
Approve — pushing side effects into background tasks is the cleaner long-term architecture, and a dedicated file makes the email logic easier to locate.
Approve — a 200 ms outbound call sitting on the request path is precisely the kind of latency a job platform is meant to take off the user’s hands.
Request changes, but only over the missing idempotency key — once the trigger is deduplicated, moving this to a task is the correct shape.

The decision tree, from request to durable job

Section titled “The decision tree, from request to durable job”

The five conditions sit at the bottom of a funnel that opens with much cheaper questions. Run it top to bottom for every new piece of work: the decision lives in the order you ask, not in any single answer. Learn the sequence, not the leaves, and a workload this lesson never mentioned still lands in the right tier. The schedule branch is the previous lesson’s, now wrapped inside the larger decision.

Where does this work run?

Take away the funnel itself: Is the user waiting? → Can it finish on this invocation? → Is it a schedule that fits? → Which of the five forced it up? Most workloads get an answer before the last question, which is why the job platform is the last tier rather than the first reach.

Why Trigger.dev, and what else is out there

Section titled “Why Trigger.dev, and what else is out there”

You know when to escalate; the open question is which tool. Several good options exist, and the course picks one on purpose.

The landscape, one line each:

  • Inngest: a serverless-native event system with step functions, similar in shape to Trigger.dev. Strongest for teams already built around events.
  • Vercel Queues: Vercel-native durable pub/sub — publish to topics, consumer groups process in the background with retries and sharding. Lighter than a full orchestration runtime, so a weaker fit for multi-step jobs that carry state. As of early 2026 it’s a public beta with at-least-once delivery, so building on its delivery semantics is a risk you take with eyes open.
  • BullMQ + Redis: full control, but you run the Redis instance and the worker yourself. Wins on hosts with persistent infrastructure, like Render or Railway.
  • AWS SQS + Lambda: enterprise scale with a heavy operational surface. Wins when you’re already deep in AWS and want the job system there too.

The course picks Trigger.dev v4 for one reason: it’s the best developer experience for a small team. You get typed payloads, durable runs, run timelines you can scrub through, durable pauses, and a local-CLI loop where you kill a run mid-flight and watch it recover. For someone shipping solo, that free observability and typed surface stands in for judgment you’d otherwise supply yourself. If cost or data residency ever forces your hand, the full platform self-hosts under Apache-2.0 on your own Docker and Postgres, with no run limits.

Now map Trigger.dev’s capabilities back onto the five conditions:

  • Durable runs answer conditions 1 and 2 (past the time wall, multi-step with state): the run checkpoints between steps and resumes on a new worker.
  • Declared retries with exponential backoff and jitter answer condition 3: you set the policy, the platform runs it.
  • Code-defined queues with concurrency limits answer condition 4: the queue holds the fan-out and meters how many run at once.
  • Waitpoints , via wait.for and wait.until, answer condition 5: the run parks and the worker goes free.
  • Typed payloads and a run dashboard sit across all five: every run, its input, and every step is visible without you building any of it.

Each is named as a capability, not code; the next three lessons write them.

Trigger.dev runs as a separate service, either its cloud or a self-hosted instance. Your app doesn’t run the task, it triggers it: an HTTPS call says “run this task with this payload,” and the work then runs on Trigger.dev’s workers, not inside the Vercel function or the Server Action that fired it.

App(Vercel function)Trigger.devservice / workersShared Postgres triggers taskover HTTPS reads / writesreads / writes
The app triggers tasks over HTTPS; the work runs on Trigger.dev's workers. Both read and write the same Postgres.

Two things follow from that picture.

First, the tasks live in your codebase, in a trigger/ folder, and ship via the Trigger.dev CLI. That makes it two deploys from one codebase, vercel deploy for the app and trigger deploy for the tasks, with types flowing between them through the shared SDK. It’s not a separate repo or language, just the same code run by a second runtime.

Second, the cost is billed on a different unit: Vercel bills per invocation, Trigger.dev per run, run-minute, and concurrency seat, so the two numbers aren’t comparable. Watch your per-task run count weekly: a sudden spike almost always means a missing idempotency key or a retry storm, not real growth.

Tasks run outside your app’s request context

Section titled “Tasks run outside your app’s request context”

A task runs on Trigger.dev’s workers, not in your Vercel function, so none of the request-scoped context your app code relies on is available. A task boots cold with nothing but its payload: no Better Auth session, no tenantDb middleware to derive the current org, no cookies, headers, or requireOrgUser(). Every helper that quietly reads “the current user” or “the current org” from the request is gone.

That leaves one rule for every task: the payload carries org context explicitly, as { organizationId, ... }, and every database call re-derives its tenant scope from that payload, via tenantDb(organizationId). The org id isn’t ambient; it’s cargo, handed across the boundary in the payload and read back out on the other side.

The two panels show that boundary. Read them for the seam, not the syntax; the SDK shapes are taught in the next lesson.

export const exportInvoices = async (formData: FormData) => {
const { orgId } = await requireOrgUser();
const since = parseSince(formData);
await tasks.trigger('export-csv', { organizationId: orgId, since });
};

The org id is handed across the boundary. The action already has orgId from requireOrgUser(), so it puts that id into the payload. The task can’t ask who the user is; the caller has to tell it.

Two failure modes are common at first. One is assuming the task shares the caller’s request context: forget to pass the org id and the task can’t scope its queries, so you get a tenancy bug or a crash. The other is subtler. The task hits the same Postgres as your request path, so a flood of concurrent tasks can contend for the connection pool against live user traffic. The fix is connection pooling with PgBouncer, set up alongside Postgres.

Which of the app’s jobs earn Trigger.dev

Section titled “Which of the app’s jobs earn Trigger.dev”

Now run the test against the app you’re building.

The CSV export earns Trigger.dev. The export you’ll build next chapter is the cleanest possible “yes”: it trips all five conditions. It’s multi-step, paginated past the time wall, has to resume if a worker crashes mid-export, fans out a unit of work per page, and emails the finished file at the end.

Everything else stays cheap, because none of it trips a condition.

WorkloadWhere it runs (and why)
CSV export of an org’s invoicesTrigger.dev, trips all five conditions
Single invitation emailInline await, one ~200 ms call the user waits on
Direct file upload (the R2 upload flow a couple of chapters from now)Inline presigned PUT, no task; the browser uploads straight to storage
Hourly trial-expiry sweepVercel Cron, a schedule that fits one invocation
Analytics event after checkoutafter(), same invocation, fire-and-forget

With the whether settled, the next lesson covers the how: the SDK, task, schemaTask, payload validation, queues, and triggering, so you can write that export task.