The answer. ctx.waitUntil(promise) keeps the invocation alive after you return the response, which is what you want for analytics, cache writes and cleanup. It is not a background job runner. For HTTP-triggered Workers the budget is 30 seconds after the response is sent or the client disconnects, shared by every waitUntil in that request; unsettled promises are cancelled and the only trace is a Workers Logs line ("waitUntil() tasks did not complete within the allowed time"). We learned this in our API Worker when a slow image-fallback fetch was quietly killed after the article response had already gone out. If the work can take longer than a few seconds, or must not be lost, put a message on a Queue (delivery is retried) or start a Workflow (durable steps). Cron, Queue and alarm handlers are different: the runtime waits for the handler promise itself, up to 15 minutes.
The pattern.
export default {
async fetch(request, env, ctx) {
const data = await handle(request, env);
ctx.waitUntil(env.CACHE.put("latest", JSON.stringify(data))); // seconds: fine here
ctx.waitUntil(logEvent(env, data).catch((e) => console.error(e))); // never let a leg throw
await env.JOBS.send({ kind: "render-images", id: data.id }); // minutes: a Queue
return Response.json(data);
},
} satisfies ExportedHandler<Env>;
Watch out.
- Do not destructure it:
const { waitUntil } = ctxthrows "Illegal invocation" at call time. - A rejected
waitUntilpromise surfaces as an unhandled exception in logs; catch inside, or one leg's failure looks like the request failed. - Streaming a response keeps the invocation alive without
waitUntil; the 30 s clock starts when the body finishes.
Related: cron-one-tick-many-isolated-legs · queues-batches-acks-and-retries · workflows-step-do-is-an-rpc-receiver