DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Background Jobs on Vercel in 2026: Field Notes on waitUntil, Queues, Workflow, and Cron

Headline: Serverless did not kill background work — it killed background work that outlives the response without telling the runtime. I now 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 "this obviously needs a queue" work now fits inside a single invocation.

Why does background work disappear after I return a response?

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. Any promise still in flight is not tracked by anything, so it is cancelled at an arbitrary point.

The failure mode that cost me 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 spinning up one instance per request. A dangling promise therefore often completes, because another request keeps the instance warm. Under low traffic it silently vanishes. Non-deterministic loss is far harder to notice in production than total loss.

// Wrong: this promise is unowned and may be killed mid-flight.
export async function POST(req: Request) {
  const body = await req.json();
  logToAnalytics(body); // no await, no waitUntil — dangling
  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

When is waitUntil() enough?

waitUntil() is enough when losing the work occasionally is acceptable and the work finishes in single-digit seconds. The function waitUntil(promise) 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.

import { waitUntil } from '@vercel/functions';

export async function POST(req: Request) {
  const body = await req.json();
  waitUntil(logToAnalytics(body)); // owned by the runtime now
  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Two honest limits I hit. 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 — waitUntil() defers the work relative to the response, not relative to the invocation. I use it for analytics events, cache warming, and log shipping. I do not use it 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. Before a queue, the p95 of my API route was the p95 of the slowest third party it called — an email provider, a PDF renderer, a webhook fan-out. After a queue, the route's p95 is 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. My default pattern is a dedupe table with a unique constraint on the message id, written before the side effect runs:

// Consumer: insert-then-act. The unique index is the guard.
const inserted = await db
  .insert(processedMessages)
  .values({ messageId })
  .onConflictDoNothing()
  .returning({ id: processedMessages.id });

if (inserted.length === 0) return; // already handled, ack and move on
await sendReceiptEmail(payload);
Enter fullscreen mode Exit fullscreen mode

The Queues API surface is still moving while it is in public beta, so treat the shape above as the pattern rather than a frozen signature and check the current docs before wiring it.

When should I 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: you mark an async function 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 whole decision for me. A queue message is atomic — if the handler throws on line 40, the entire 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.

'use workflow';

export async function onboardCustomer(customerId: string) {
  const account = await createAccount(customerId); // step 1, checkpointed
  await provisionResources(account.id);            // step 2, checkpointed
  await sleep('24 hours');                         // survives a redeploy
  await sendDayTwoEmail(account.id);               // step 4
}
Enter fullscreen mode Exit fullscreen mode

The other thing Workflow buys is 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 I pick between cron, waitUntil, Queues, and Workflow?

Primitive Trigger Durable? Retries Use it for
waitUntil() Request No None Analytics pings, cache warming, log shipping
Vercel Cron Clock Yes (the schedule) Next tick Nightly sweeps, expiry, report generation
Vercel Queues Producer message Yes At-least-once redelivery Email sends, webhook fan-out, image processing
Vercel Workflow Explicit invocation Yes (per step) Per step, resumes at checkpoint Onboarding sequences, multi-provider orchestration, long 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:

import { type VercelConfig } from '@vercel/config/v1';

export const config: VercelConfig = {
  crons: [{ path: '/api/cleanup', schedule: '0 3 * * *' }],
};
Enter fullscreen mode Exit fullscreen mode

What actually broke for me?

Three things broke, all of them in the gap between "the primitive works" and "my handler respects the primitive's contract."

A duplicate side effect from at-least-once delivery. A consumer that sent a confirmation email had no dedupe guard. A transient failure after the send but before the ack caused redelivery, and the same user got the same email twice. The fix was the insert-then-act pattern above: write the message id under a unique constraint first, and treat a conflict as "already done."

A long job parked in waitUntil(). I put a multi-minute document job behind 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 day one; waitUntil() was the wrong contract, not a tuning problem.

Overlapping cron runs. A nightly sweep that normally took a few minutes grew past its own interval, and two invocations ran concurrently over the same rows. Vercel Cron does not serialize overlapping executions for you. I added a Postgres advisory lock at the top of the handler and made the second run exit 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 I 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 my consumer code?

A: It means your 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 I should write background work?

A: Yes, in one specific way: Fluid Compute reuses instances across concurrent requests, so dangling promises often complete by accident. That 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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)