Background Jobs on Vercel in 2026: How We Choose Between waitUntil, Queues, Workflow, and Cron
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Serverless did not kill background work — it killed background work that outlives the response without telling the runtime. At Devya we route every deferred task on Vercel through one of four primitives: waitUntil() for short best-effort side effects, Vercel Cron for clock-triggered sweeps, Vercel Queues for work that must survive a failing consumer, and Vercel Workflow for multi-step jobs that must survive a redeploy an hour later.
Key takeaways
waitUntil() from the @vercel/functions package extends a Vercel Function past its response so a pending promise can finish, but it is best-effort: no retries, no durability, and it dies with the invocation.
Vercel Queues is a durable event-streaming service with at-least-once delivery, which means every consumer must be idempotent — some message will eventually be delivered twice.
Vercel Workflow provides durable execution: an async function marked with the "use workflow" directive checkpoints each step, so a crash resumes at the last completed step instead of restarting from the top.
Vercel Cron is correct only for time-triggered work. A job triggered by a user action belongs in a queue, not on a schedule.
Vercel Functions default to a 300-second max duration on all plans in 2026, so a surprising amount of work that seems to need a queue now fits inside a single invocation.
Why does background work disappear after a response is returned?
Background work disappears because an unawaited promise has no owner. When a Vercel Function returns a Response, the platform is free to freeze or reclaim that instance immediately, and any promise still in flight is cancelled at an arbitrary point.
The failure mode that costs our team the most debugging time is not that the work never runs — it is that the work runs sometimes. Fluid Compute, the default compute model on Vercel, reuses a single function instance across concurrent requests instead of creating one instance per request. A dangling promise therefore often completes because another request keeps the instance warm, and silently vanishes under low traffic. Non-deterministic loss is far harder to notice in production than total loss.
When is waitUntil() enough?
waitUntil() is enough when losing the work occasionally is acceptable and the work finishes in single-digit seconds. The waitUntil(promise) function is exported from @vercel/functions and registers a promise with the runtime, so the invocation stays alive until that promise settles even though the response has already been sent.
There are two honest limits. First, waitUntil() has no retry semantics: if the promise rejects, nothing re-runs it and the rejection surfaces only in runtime logs. Second, the deferred work still counts against the function's max duration and against Active CPU billing, because waitUntil() defers work relative to the response, not relative to the invocation. We use it for analytics events, cache warming, and log shipping — never for anything a user would file a support ticket about.
What does Vercel Queues actually solve?
Vercel Queues solves the case where the work must eventually happen even if the first attempt fails. Vercel Queues is a durable event-streaming system built on Fluid Compute, currently in public beta, that provides at-least-once delivery: a producer writes a message to a topic and returns immediately, and a separate consumer function processes that message with retries on failure.
The architectural win is decoupling latency budgets. Without a queue, the p95 of an API route is the p95 of the slowest third party it calls — an email provider, a PDF renderer, a webhook fan-out. With a queue, the route's p95 becomes the cost of one durable write, and the third party's bad afternoon becomes a retry curve on the consumer instead of a timeout on the user's request.
The tax is idempotency, and it is not optional. At-least-once delivery means duplicate delivery is a certainty over a long enough window, not an edge case. Our default pattern is a dedupe table with a unique constraint on the message id: insert the message id first, treat a conflict as already handled and return, and only run the side effect when the insert actually created a row.
The Queues API surface is still moving while it is in public beta, so treat that pattern as the shape rather than a frozen signature and confirm against the current documentation before wiring it.
When should we use Vercel Workflow instead of a queue?
Use Vercel Workflow when the retry unit is a single step inside a longer job, not the whole message. Vercel Workflow is a durable execution framework: an async function is marked with the "use workflow" directive and its individual steps with "use step", and the runtime checkpoints each completed step's result so an interruption resumes from the last checkpoint instead of re-running everything.
That distinction is the entire decision. A queue message is atomic — if the handler throws on line 40, the whole message is redelivered and lines 1 through 39 run again. That is fine when those lines are pure. It is not fine when line 12 charged a card and line 40 failed to render a PDF.
Workflow also buys time. A durable workflow can sleep for hours or days and wait for an external event, because its state lives outside any single function invocation. A queue consumer cannot — it is still a function bounded by the 300-second max duration.
How do we pick between cron, waitUntil, Queues, and Workflow?
waitUntil() — triggered by a request, not durable, no retries. Use for analytics pings, cache warming, and log shipping.
Vercel Cron — triggered by the clock, durable schedule, effectively retried on the next tick. Use for nightly sweeps, expiry jobs, and report generation.
Vercel Queues — triggered by a producer message, durable, at-least-once redelivery. Use for email sends, webhook fan-out, and image processing.
Vercel Workflow — triggered by explicit invocation, durable per step, resumes at the last checkpoint. Use for onboarding sequences, multi-provider orchestration, and long-running jobs.
Vercel Cron is configured declaratively. In vercel.ts, the recommended TypeScript project configuration that replaces vercel.json, it is a crons array of path-and-schedule pairs, for example the path /api/cleanup on the schedule 0 3 * * *.
What actually broke for us?
Three things broke, all in the gap between the primitive working and the handler respecting the primitive's contract.
A duplicate side effect from at-least-once delivery. A consumer that sent a confirmation email had no dedupe guard, and a transient failure after the send but before the ack caused redelivery, so the same user received the same email twice. The fix was insert-then-act: write the message id under a unique constraint first and treat a conflict as already done.
A long job parked in waitUntil(). A multi-minute document job was deferred with waitUntil() because it was the smallest diff. It worked in staging and lost work in production during deploys, because a rolling deploy retires the old instance and the in-flight promise goes with it. That job belonged in a queue from the start — waitUntil() was the wrong contract, not a tuning problem.
Overlapping cron runs. A nightly sweep grew past its own interval and two invocations ran concurrently over the same rows. Vercel Cron does not serialize overlapping executions. We added a Postgres advisory lock at the top of the handler so the second run exits immediately instead of contending.
The pattern behind all three: pick the primitive by the failure you can tolerate, not by the code you can write fastest. Best-effort work gets waitUntil(). Must-happen work gets a queue and an idempotency key. Must-happen-in-order work gets a workflow. Clock work gets cron and a lock.
FAQ
Q: Does waitUntil() let a Vercel Function run longer than its max duration?
A: No. waitUntil() keeps the invocation alive after the response is sent, but the invocation is still bounded by the function's max duration, which defaults to 300 seconds on all plans in 2026. It defers work relative to the response, not relative to the invocation.
Q: Do we still need a third-party queue like SQS, BullMQ, or Inngest on Vercel?
A: Not for the common cases. Vercel Queues covers durable at-least-once messaging and Vercel Workflow covers durable multi-step execution, both natively on Fluid Compute. Reach for an external system when you need semantics they do not offer, such as strict FIFO ordering per key or exactly-once processing enforced by the broker.
Q: What does at-least-once delivery mean in practice for consumer code?
A: It means the consumer will receive the same message more than once at some point, so every side effect must be safe to repeat. Guard non-idempotent effects — charges, emails, external POSTs — with a dedupe record keyed by the message id and written under a unique constraint before the effect runs.
Q: Can Vercel Cron trigger a queue producer instead of doing the work itself?
A: Yes, and that is usually the better design for large sweeps. Have the cron route enumerate the work and publish one message per item, then let queue consumers process items in parallel with independent retries. The cron invocation stays short and a single bad item cannot fail the entire sweep.
Q: Does Fluid Compute change how background work should be written?
A: Yes, in one specific way. Fluid Compute reuses instances across concurrent requests, so dangling promises often complete by accident, which makes unowned background work look correct in testing and fail intermittently in production. Always register deferred work explicitly with waitUntil() or hand it to a queue.
Further Reading
Full-Stack Engineering
Content Security Policy in the Next.js App Router: Nonces, strict-dynamic, and the Static-Rendering Trade-Off
At Devya we rolled a nonce-based Content Security Policy across our Next.js App Router applications. A per-request nonce plus 'strict-dynamic' is the only script policy that survives the framework's runtime chunk loading — and it quietly converts static routes into dynamic ones. These are our field notes on the trade-off and how we scoped it.
Full-Stack Engineering
Passkeys in Production: What We Learned Shipping WebAuthn, Conditional UI, and RP ID Rules
A passkey is a WebAuthn credential permanently bound to one Relying Party ID. Our team traced almost every passkey bug back to three details: the RP ID matching rules, using an email as user.id, and expecting the browser autofill prompt without conditional mediation. These are our field notes from shipping passkeys on Next.js.