Engineering decisions
Background jobs in Next.js when there is no server to run them on
Pick by two things: how long the job runs, and what happens if it vanishes. If it finishes inside your route’s time limit and losing it costs you nothing, use after(). If either half of that is false, the work has to leave the request: write a row, and have something else drain it. There are five mechanisms that actually exist for this, and almost everything else on the market is one of them wearing a logo.
The confusion is structural. A Next.js App Router deployment on a serverless platform has no process that outlives the response, so the habit every backend developer already has, kick off a job, return, let it run, is the one thing the runtime will not do for you.
The five mechanisms, side by side
Read the middle two columns first. They are the questions that change the answer.
| Mechanism | Outlives the response? | Survives a deploy or crash? | What it costs you |
|---|---|---|---|
after() / waitUntil | Only up to the route’s max duration | No | Nothing new, which is the whole appeal |
| Cron sweeper over a table | Yes | Yes | Latency equal to your tick interval |
| Postgres queue, SKIP LOCKED | Yes | Yes, with a reaper | You own retries, backoff and the dead letter table |
| Hosted durable workflow | Yes | Yes | A vendor, per-step billing, control flow in their API |
| Worker on a long-lived host | Yes | Yes, with a supervisor | A box that is always on and always yours to patch |
after() and waitUntil solve one narrow problem well
after() went stable in Next.js 15.1 and lives in next/server. It schedules a callback for once the response has been sent, from a Server Component, Server Function, Route Handler or proxy. On a serverless platform it reaches for that platform’s waitUntil, which extends the invocation until the promise settles.
What it actually guarantees
Less than people assume. Three things are worth having memorised.
It runs inside your route’s configured max duration, not on top of it. Sending the response does not reset a clock. If the route is capped at 60 seconds and rendering took 4, the callback has 56.
Inside a Server Component you cannot call cookies() or headers()within the callback. It throws, because the callback runs past React’s render lifecycle. Read the values first and close over them. In Route Handlers and Server Functions you can call them inside, which is a confusing asymmetry to discover at 6pm.
Calling it does not make a route dynamic. On a static page the callback fires at build time, or whenever the page revalidates. That is either what you wanted or a very quiet bug.
Where we would use it, and where we would not
We use it for analytics events, audit log writes, cache warming, a Slack ping, cleanup after a mutation. Losing any of those during a bad deploy is an inconvenience, not an incident.
We would not put anything a customer paid for behind it. No invoice generation, no provisioning, no email confirming a transaction. There is no retry, no visibility, and no record that the work was supposed to happen. When the invocation dies the intent dies with it, and nothing anywhere knows to feel bad about that.
The function ceiling is the wall you hit first
On Vercel, the number most teams collide with is a 300 second maximum duration, configurable downward per route with export const maxDuration. Higher ceilings exist on paid plans and the defaults have moved more than once. Treat 300 as the shape of the problem, and read the current limits page before designing against a specific figure.
Other runtimes draw the line elsewhere. Netlify background functions are measured in minutes. Cloudflare Workers bill CPU time rather than wall clock, so a job that spends its life waiting on somebody else’s API is priced completely differently there. A Node process in a container has no ceiling at all, which is the real reason people keep telling you to move.
The useful reframe: the ceiling limits one attempt at your job, not the job. A 40 minute import is not a 40 minute function. It is 400 units of work, each small enough to finish in a couple of seconds, plus a durable record of which ones are done. That makes the platform limit irrelevant rather than hostile.
A cron sweeper is the cheapest durability you can buy
Before any queue, add a scheduled route that finds work which should have happened and did not. One query, one loop.
This is the same shape as the reconciliation job in running Stripe webhooks in production, for the same reason. Any delivery mechanism you do not control will eventually not deliver. A sweeper converts a silent permanent failure into a delay bounded by your tick interval.
Two constraints going in. Scheduler granularity varies by vendor and plan, and on the cheapest tiers can be as coarse as once a day, which quietly sets your worst case latency. And GitHub Actions schedules are explicitly best effort: they routinely fire late under load. Fine for a nightly cleanup, unfit as a heartbeat for anything with a deadline.
Keep the sweeper even after you build a real queue. It is the thing that catches the bug in the queue.
A Postgres queue with SKIP LOCKED is the default answer
If you already run Neon, Supabase, RDS or any other Postgres, this is where we start, and plenty of products never leave. You get transactional enqueue for free: the job row and the business rows commit together, so you can never hold a job for an order that does not exist. That is the most common bug in Redis-backed queues, and here it is structurally impossible.
The table
create table jobs (
id bigserial primary key,
kind text not null,
payload jsonb not null,
idempotency_key text not null,
status text not null default 'queued',
attempts int not null default 0,
run_after timestamptz not null default now(),
locked_at timestamptz,
last_error text,
unique (kind, idempotency_key)
);
create index on jobs (run_after) where status = 'queued';The partial index matters more than it looks. Without it the claim query degrades as finished rows accumulate, and the symptom is a queue that gets slower every week for no reason anybody can name.
The claim
update jobs
set status = 'running', locked_at = now(), attempts = attempts + 1
where id = (
select id from jobs
where status = 'queued' and run_after <= now()
order by run_after
for update skip locked
limit 1
)
returning *;skip locked is what makes this safe with many concurrent workers: each steps over rows another has locked instead of queueing behind them. It has been in Postgres since 9.5, which is why this pattern is boring rather than clever.
On throughput, our own working number rather than a published benchmark: a modest Postgres handles queue traffic in the low thousands of jobs per minute without complaint. The limiter is usually payload size and dead-row bloat, not the claim. Enqueue faster than autovacuum reclaims and bloat gets you long before lock contention does.
One Neon and Supabase specific: connection pooling in transaction mode breaks LISTEN and NOTIFY. If the plan was to wake a worker with a notify rather than polling, check which port and pooling mode you are on first. Polling every two to five seconds is unglamorous and never surprises anybody.
Reaping what a deploy killed
A deploy replaces the running instances, and any job claimed but not finished sits in running forever with nobody on it. The fix is a visibility timeout you enforce yourself: any row in running whose locked_at is older than a few minutes goes back to queued. Set that window above your slowest legitimate job or you will double-run healthy work. Which is exactly why the next section is not optional.
Where the Postgres queue we just recommended stops being right
We push this pattern hard, so here is the bill. You have written a queue. Retry policy, backoff, a dead letter table, priority, rate limiting, a reaper, metrics, and an admin view to retry a row by hand. None of it is difficult. All of it is yours forever, and it is the kind of code that gets written once and understood by one person.
The polling loop also sets a latency floor. If a job has to start within 200 milliseconds of being enqueued, polling is the wrong shape and you want a real broker.
There are no fan-out primitives either. Anything shaped like “run these forty in parallel, wait for all of them, then do one more thing” is a state machine you are hand-writing in SQL. At high volume, SQS or Cloudflare Queues cost less per month than the engineering time spent keeping your own version honest. If you are already there, buy the queue.
Hosted durable workflows: when the retry policy is the product
Inngest, Trigger.dev, Temporal, QStash and Vercel’s own workflow and queue products all sell the same core idea. Your function becomes a sequence of durable steps, each one checkpointed, so a crash resumes at the last completed step instead of the beginning.
That is worth real money in one case: multi-step jobs with fan-out, long waits, or a human in the middle. A twelve step onboarding that pauses seven days for a signature is miserable to model in a jobs table and pleasant to model as a durable workflow.
What you buy is replay semantics, a UI your support team can use, and per-step observability. What you pay is per-step billing that gets interesting at volume, a second place where production state lives, and control flow expressed in someone else’s API. Migrating off is a rewrite of every workflow, not a config change. Not a reason to avoid it, only a reason to know it in year one rather than year two.
A real worker process, and when you genuinely need one
A long-lived Node process on Railway, Render, Fly, ECS or a plain VM, reading the same Postgres table, is often the smallest change available. Your Next.js app stays where it is. One extra service does the work that does not fit in a request.
Reach for it when the job is CPU bound (video, large PDFs, image pipelines), needs local disk or a warm cache, holds a persistent connection, or simply runs for hours. Real-time voice is the clearest example of work that cannot live in a request cycle, which is part of why the desktop voice agent we built for a client is a desktop application rather than a route handler.
The cost is one more thing to deploy, monitor and patch, plus a supervisor to restart it. If your team already runs containers, this is the cheapest option on the page. If nobody wants to own a box, say so. An unowned worker is worse than no worker.
Every handler needs an idempotency key before it needs a retry policy
This is the part vendor documentation rushes. Every mechanism above except after() is at-least-once. Retries, reaped locks and duplicate enqueues all mean a handler will eventually run twice on the same input. Exactly-once delivery is not something you buy. Exactly-once effect is something you implement, and it is a unique index.
Derive the key from the work, not the attempt. A UUID generated when the job is created is stable across retries and makes a fine key. A timestamp does not. Compose it from the business fact where you can: invoice:2026-08:org_913 explains itself in a database console at 3am, which is when you will be reading it.
Then let the database enforce it. Write the result through an insert ... on conflict do nothingagainst a unique constraint, in the same transaction that marks the job done. Checking “have I done this already?” in application code first is a race, and two workers will lose it eventually.
For calls to third parties, pass the same key through. Stripe and a growing number of other APIs accept an idempotency header and deduplicate on their side. Where they do not, record your own outbound attempt row before the call and reconcile afterwards.
Dead-letter depth is a leading indicator, not a bin
A dead letter table, where jobs land after their final attempt, is standard. Treating it as storage is where teams go wrong. It is a sensor, so read it like one.
Alert on the rate of change, not the absolute count. A table gaining three rows an hour, every hour, tells you something is broken roughly a week before the total crosses whatever round number you would have picked as a threshold. Absolute-count alerts fire late by construction.
Two more habits worth twenty minutes. Store the error text and the attempt count on the row, because “it failed” is not a debugging aid. And bound what you retry: a poison job that fails deterministically burns worker capacity on every sweep until somebody notices.
Backoff should be exponential with jitter, not exponential alone. Without jitter, a downstream outage produces a herd of retries that arrive together the moment it recovers, which is an efficient way to cause the second outage yourself.
When a queue is premature and one cron row is enough
Plenty of teams asking this question do not need a queue yet, and building one early is a real cost dressed up as diligence.
If the job is “send a welcome email after signup”, your email provider already has scheduling and retries. If it is “generate a monthly report”, that is a cron route and a status column. If it is “resize an upload”, your storage or image provider almost certainly does it on request, and the best background job is the one that turns out to be a URL parameter.
Our threshold, plainly. Build the queue when you have three or more distinct job types, or when a failed job costs money, or when the work cannot finish inside the function ceiling. Below that, a status column plus a sweeper is the whole system, and it reads clearly to whoever inherits it.
The threshold arrives on its own. A multi-company HR and payroll portal like Clora AI, our own product, is the shape of system where a payroll run cannot be a request however the deployment is configured. It does not need anticipating in month one.
The compressed decision
Use after() for work you can afford to lose, and be strict about what qualifies. Add a cron sweeper the day you have anything durable. Put a jobs table in the Postgres you already run and drain it with skip locked, then stay there until fan-out, sub-second dispatch or volume pushes you off. Buy a durable workflow product when multi-step retry semantics are the actual product. Stand up a worker when the work is long, heavy or stateful.
Whichever you pick, write the idempotency key first. Every other decision on this page can be reversed in an afternoon. Running a payment, an email or a provisioning step twice cannot.
This is ordinary application and platform engineering, bound tightly to what your deployment target will and will not do, which is the same territory as why your Next.js site is slow. If a job of yours keeps getting killed and you want a second opinion on which of the five it should be, tell us what it does and how long it takes.