Using ctx.waitUntil Patterns for Reliable workerd Tasks



Serverless handlers die fast. A Cloudflare Worker processes a request, returns a response, and the runtime is ready to reclaim resources. Any work that hasn’t finished gets dropped. This is the core problem that ctx.waitUntil() solves, and understanding its patterns is essential if you want background tasks that actually complete.

This guide covers everything you need to know about using ctx.waitUntil patterns for reliable workerd tasks: the API itself, proven production patterns, common pitfalls that cause silent data loss, and a clear decision framework for when to reach for something else entirely.

If you’re instrumenting a Cloudflare Worker with metrics or telemetry, this is especially relevant. The flush-after-response pattern is the single most common use of waitUntil, and getting it wrong means lost data.


What Is ctx.waitUntil()?

ctx.waitUntil() is a method on the execution context object passed to your Worker’s handler. It accepts a Promise, and the Workers runtime will continue executing that Promise even after a response has been returned to the client.

The function signature is simple:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    // Build and return the response immediately
    const response = new Response("OK");

    // Schedule background work
    ctx.waitUntil(doSomethingAsync());

    return response;
  },
};

The key constraint: for HTTP-triggered Workers, ctx.waitUntil() can extend execution for up to 30 seconds after the response is sent or the client disconnects. This 30-second budget is shared across all waitUntil() calls within the same request. If any Promises haven’t settled after 30 seconds, they’re canceled.

You can call waitUntil() multiple times safely. Similar to Promise.allSettled, if one waitUntil promise rejects, the others keep running. This matters when you’re firing off independent tasks like flushing metrics to one service and sending a webhook to another.

When tasks are canceled, the runtime logs a warning to Workers Logs and any attached Tail Workers: “waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.”


How ctx.waitUntil() Relates to workerd

workerd is the open-source runtime that powers Cloudflare Workers in production. When you develop locally using wrangler dev, your code runs on Miniflare v3, which executes your Worker code using the same workerd runtime used in production. This means waitUntil behavior in local development should match production, with one important caveat.

When using getPlatformProxy (common in framework setups like Next.js or Remix), the ctx object contains mock implementations of waitUntil and passThroughOnException that do nothing. Your background work simply won’t execute. This is a blind spot you must account for when testing locally through framework adapters.

For a deeper look at how serverless runtimes handle execution lifecycles, see our overview of what serverless means in practice.


Common Patterns for Using ctx.waitUntil

Metrics Flush

This is the canonical use case. Your Worker records counters or histograms during request handling, then flushes them to your metrics backend without making the user wait for network round-trips.

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const start = Date.now();

    // Handle the request
    const response = await handleRequest(request, env);

    // Record metrics
    metrics.counter("requests_total", 1, { status: response.status });
    metrics.histogram("request_duration_ms", Date.now() - start);

    // Flush in the background
    ctx.waitUntil(metrics.flush());

    return response;
  },
};

The flush semantics here are critical. PostHog’s Cloudflare Workers integration, for example, recommends setting flushAt to 1 and flushInterval to 0 to avoid batching-related data loss in ephemeral environments. LaunchDarkly similarly requires calling flush inside waitUntil to ensure events reach their servers without impacting response time.

The pattern is consistent: record during the request, flush after the response.

Get started with Distlang Metrics for a Cloudflare Workers quickstart that demonstrates this exact pattern.

Cache Write-Behind (Stale-While-Revalidate)

Return a stale cached response immediately, then refresh the cache in the background:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const cached = await caches.default.match(request);

    if (cached) {
      // Return stale data now, refresh later
      ctx.waitUntil(refreshCache(request, env));
      return cached;
    }

    const fresh = await fetchOrigin(request);
    ctx.waitUntil(caches.default.put(request, fresh.clone()));
    return fresh;
  },
};

This keeps response latency tight while ensuring caches stay warm. The Cloudflare best practices documentation explicitly recommends this approach: await only what you must, push refreshes into ctx.waitUntil.

External Logging and Analytics

Fire an HTTP request to an external analytics provider without blocking the response. This works well for webhook notifications, audit logs, or forwarding structured events to a logging pipeline.

ctx.waitUntil(
  fetch("https://logs.example.com/ingest", {
    method: "POST",
    body: JSON.stringify({ event: "page_view", path: request.url }),
  })
);

For guidance on buffering and flush strategies that minimize request overhead, batch size matters more than you might expect.

Connection Cleanup

A pattern seen in production workerd GitHub issues: using ctx.waitUntil(connection.end()) to gracefully close database connections after returning the response. This prevents connection leaks without adding latency to the user-facing path.


The import { waitUntil } Alternative

In August 2025, Cloudflare introduced a significant ergonomics improvement: you can now import waitUntil directly from the cloudflare:workers module.

import { waitUntil } from "cloudflare:workers";

// Deep inside a utility module
export async function trackEvent(event: string) {
  waitUntil(
    fetch("https://analytics.example.com", {
      method: "POST",
      body: JSON.stringify({ event }),
    })
  );
}

Previously, if you needed to schedule background tasks from deeply nested functions or utility modules, you had to pass the ctx object through multiple function calls. This “prop-drilling” problem was especially painful for library authors and metrics clients that needed background-task capability.

The new import pattern eliminates this entirely. It’s particularly useful for framework integration, where practitioners on Cloudflare’s Discord have reported that ctx was not being passed through framework adapters at all. One SvelteKit developer found that their caching logic couldn’t use waitUntil because the framework’s request object didn’t expose the execution context.

If you’re building reusable code that runs on Workers, prefer the import pattern. If you’re writing a top-level handler and already have ctx in scope, either approach works.


Pitfalls and Gotchas

Using ctx.waitUntil patterns for reliable workerd tasks requires knowing where developers consistently get burned. These aren’t theoretical risks; they come from production incidents shared on Discord, GitHub, and developer forums.

Destructuring ctx Causes “Illegal Invocation”

// DON'T DO THIS
const { waitUntil } = ctx;
waitUntil(somePromise); // Throws "Illegal invocation"

Destructuring ctx loses the this binding. Always call ctx.waitUntil() directly, or use the new import-based waitUntil from cloudflare:workers.

Reading the Request Body After Response

A developer on Cloudflare’s Discord (April 2025) reported that after two years of using ctx.waitUntil without issues, all their Workers suddenly started failing with: “Can’t read from request stream after response has been sent.”

The root cause: they were calling request.text() inside the waitUntil callback after the response had already been sent. The request body is consumed during the main request path. It’s not available inside waitUntil. No ranking page explains this clearly, but it’s a common production foot-gun.

The fix: read and store the request body before returning the response, then pass the stored value into your waitUntil callback.

const body = await request.text(); // Read BEFORE response
const response = new Response("OK");
ctx.waitUntil(processBody(body)); // Use the stored value
return response;

Passing Non-Promises Creates Ghost Timeouts

Practitioners on Discord have reported IoContext timeout warnings where wall time balloons to 30 seconds for no apparent reason. The cause: passing a non-Promise value (or a Promise that never settles) to waitUntil. The runtime waits the full 30-second window before giving up.

Always ensure the value you pass to waitUntil is a Promise that will actually resolve or reject.

Floating Promises: The Silent Killer

A Promise that is not awaited, returned, or passed to ctx.waitUntil() is a floating promise. The Workers runtime may terminate your isolate before a floating promise completes, causing dropped results, swallowed errors, and unfinished work.

Enable the @typescript-eslint/no-floating-promises lint rule to catch these during development. This single rule prevents an entire class of silent data loss bugs.

Wrangler Local Dev Regressions

A developer reported that starting with wrangler@3.50.0, a while loop inside context.waitUntil in a Pages Function was interrupted after its first fetch, while it worked correctly in wrangler@3.49.0. Local dev tooling has had regressions that break waitUntil behavior, making production testing essential.

Understanding the configuring flush behavior of your metrics client helps you diagnose whether data loss originates from waitUntil cancellation or from flush timing.


When NOT to Use ctx.waitUntil()

Knowing when to reach for a different tool is just as important as knowing the patterns themselves. Here’s a clear framework.

Not for Long-Running Jobs

A developer on dev.to recounted their experience: “I’ll just use ctx.waitUntil() to let it finish after the response!” But waitUntil doesn’t extend the timeout. It just lets you do cleanup work after sending the response. The isolate still shuts down at the same time limit. If your task takes more than 30 seconds post-response, it gets canceled.

Not for Retryable or Critical Work

There are no retries. There are no failure handling tools. If the Promise rejects, the error is logged but nothing else happens. If your waitUntil task is sending a payment confirmation email, you’ve chosen the wrong mechanism.

Not for Keeping Durable Objects Alive

Durable Objects remain active while handling requests, RPC calls, response streams, WebSockets, or pending I/O. DurableObjectState.waitUntil() exists for API compatibility only and is not needed for this behavior.

Decision Table: Which Background Mechanism to Use

Mechanism Delivery Guarantee Max Duration Retries Best For
ctx.waitUntil() Best-effort (30s window) 30s post-response None Metrics flush, cache writes, lightweight logging
Tail Workers Runs even on uncaught exceptions Same as Worker limits None Log/error forwarding
Cloudflare Queues At-least-once Separate consumer budget Built-in + DLQ Async jobs, email, webhooks
Workflows Durable, step-level Hours/days/weeks Per-step Multi-step processes
Analytics Engine Fire-and-forget N/A Platform-managed CF-native metrics

Use Queues when you need to decouple a producer from a consumer. Use Workflows when the background work has multiple steps that depend on each other, where each step’s return value is persisted and only failed steps are retried.

For a cross-platform comparison, Vercel offers a similar pattern via after(). See how Vercel’s after() works for metrics flushing if you’re building across both platforms.


Testing waitUntil in Development

Reliable background tasks require reliable tests. Here’s how to verify your waitUntil patterns actually work.

Vitest with @cloudflare/vitest-pool-workers

The @cloudflare/vitest-pool-workers package provides two essential helpers:

  • createExecutionContext() creates a mock ctx object
  • waitOnExecutionContext(ctx) waits for all Promises passed to ctx.waitUntil() to settle before you run assertions
import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";

it("flushes metrics after response", async () => {
  const ctx = createExecutionContext();
  const response = await worker.fetch(request, env, ctx);

  // Wait for all waitUntil promises to complete
  await waitOnExecutionContext(ctx);

  // Now assert on side effects
  expect(response.status).toBe(200);
  expect(mockMetricsEndpoint).toHaveBeenCalled();
});

getPlatformProxy Limitations

When using getPlatformProxy (the standard approach for framework-based local dev with Next.js, Remix, etc.), the waitUntil function is a no-op mock. Your background work won’t execute. If your application depends on waitUntil side effects, you must test with Vitest pool-workers or deploy to a staging environment.

Wall Time Includes waitUntil Work

One thing that catches developers off guard: the wall time per execution metric in the Workers dashboard measures the total time the JavaScript context remained open, including time spent in waitUntil() handlers. If your metrics show unexpectedly long wall times, check your waitUntil promises for slow I/O or unsettled promises.


Metrics Flushing as the Canonical waitUntil Pattern

Every metrics, analytics, or telemetry client running inside a Cloudflare Worker faces the same fundamental problem: the handler is short-lived, and buffered data must be sent before the isolate dies.

The solution is always the same pattern:

  1. Record metrics during request handling (counters, histograms, events)
  2. Call flush() on your client to trigger an HTTP send
  3. Pass the flush Promise to ctx.waitUntil() so it completes after the response

This pattern appears across every major analytics SDK that supports Workers. PostHog disables batching entirely in Workers to avoid data loss. LaunchDarkly’s documentation explicitly says you must flush inside waitUntil or events won’t reach their servers.

The details of how your client buffers and flushes matter enormously in these environments. Too much batching and you lose data when the isolate terminates. Too little and you’re making excessive network calls. For a deeper look at these tradeoffs, see our guide on buffering and batching strategies.

If you need metrics for a Cloudflare Worker without running agents, pipelines, or time-series databases, Distlang Metrics is built specifically for this use case, with a JS client designed around the flush-after-response lifecycle.

See the Cloudflare quickstart to go from code to dashboard in minutes.


Frequently Asked Questions

What happens if a ctx.waitUntil() promise rejects?

The rejection is logged, but it doesn’t affect other waitUntil promises. The behavior mirrors Promise.allSettled: each promise is independent. However, there are no automatic retries. If the rejected work was important, you need a different mechanism like Queues.

Can I use ctx.waitUntil() in a Durable Object?

DurableObjectState.waitUntil() exists for API compatibility, but Durable Objects don’t need it. They remain active as long as they’re handling requests, RPC calls, WebSockets, or pending I/O. Using waitUntil in a Durable Object for keep-alive purposes is unnecessary.

Does the 30-second limit apply to the total request or just the post-response period?

Just the post-response period. The 30-second clock starts after the response is sent or the client disconnects. It’s not a limit on the total wall time of the HTTP request itself. This budget is shared across all waitUntil calls in the same request.

Why do I get “Illegal invocation” when calling waitUntil?

You’re likely destructuring the execution context: const { waitUntil } = ctx. This breaks the this binding. Either call ctx.waitUntil() directly or use the newer import { waitUntil } from "cloudflare:workers" pattern.

Can I read the request body inside a waitUntil callback?

No. The request stream is consumed during the main request path. If you need the body in your background task, read it before returning the response and pass the stored value into your callback. This is a common production bug that isn’t well documented.

How do I test waitUntil behavior locally?

Use @cloudflare/vitest-pool-workers with createExecutionContext() and waitOnExecutionContext(ctx). The getPlatformProxy approach used by frameworks like Next.js provides only a no-op mock, so waitUntil work won’t actually execute in that environment.

Should I use waitUntil or Tail Workers for logging?

If your logging must survive uncaught exceptions, use Tail Workers. They execute regardless of the Worker’s invocation status. waitUntil is fine for logging in the happy path, but if your Worker throws before reaching the waitUntil call, those logs are lost.

What’s the difference between waitUntil and Cloudflare Queues?

waitUntil is best-effort with a 30-second window and no retries. Queues provide at-least-once delivery with built-in retries and dead-letter queues. Use waitUntil for non-critical work like metrics and cache warming. Use Queues for anything that needs delivery guarantees, like sending emails or processing webhooks.