How to Instrument Cloudflare Workers: waitUntil() Semantics



Cloudflare Workers run in V8 isolates across 300+ data centers. They’re fast. They’re also ephemeral, which makes them hard to observe. There’s no filesystem to write logs to, no long-running process for an APM agent to attach to, and no guarantee your isolate sticks around after the response ships. Every piece of telemetry you want to send to an external system, whether metrics, traces, or structured logs, needs to leave the isolate before it vanishes.

That’s what waitUntil() solves. It’s the foundation of all external instrumentation in Cloudflare Workers, and understanding its semantics is the difference between reliable observability and silently dropped data.

If you’re looking for a serverless-native metrics solution that works with these patterns out of the box, Distlang Metrics is designed for exactly this use case.

What Is waitUntil() in Cloudflare Workers?

waitUntil() extends the lifetime of your Worker, allowing you to perform work that continues after a response has been returned to the client. You pass it a Promise, and the Workers runtime keeps the isolate alive until that promise settles or 30 seconds elapse, whichever comes first.

The Cloudflare docs define it plainly: it “accepts a Promise, which the Workers runtime will continue executing, even after a response has been returned by the Worker’s handler.”

Two Ways to Access It

Historically, you could only call waitUntil() through the execution context parameter (ctx) passed to your Worker’s handler:

export default {
  async fetch(request, env, ctx) {
    const response = new Response("Hello");
    ctx.waitUntil(sendMetrics());
    return response;
  }
};

This worked fine for simple handlers, but it forced you to thread ctx through every function call if you needed waitUntil deep in your code.

As of August 2025, you can import waitUntil directly from cloudflare:workers:

import { waitUntil } from "cloudflare:workers";

function sendAnalytics(data) {
  waitUntil(fetch("https://metrics.example.com", {
    method: "POST",
    body: JSON.stringify(data),
  }));
}

The imported version works identically to ctx.waitUntil(). It just removes the ergonomic pain of passing context objects through deeply nested function calls and utility modules.

Multiple Calls and Promise.allSettled Behavior

You can call waitUntil() multiple times within the same request. These calls behave like Promise.allSettled: if one promise rejects, the others continue executing. This matters for instrumentation because you might be sending metrics to one endpoint and logs to another. A failure in one shouldn’t cancel the other.

Why Instrumentation Needs waitUntil()

The core problem is simple. Without waitUntil(), any asynchronous work that hasn’t completed when the response is sent gets cancelled. The Workers runtime has no reason to keep the isolate alive once the client has its response.

This is true for every fetch() call that sends data to an external service. If you fire off a POST to your analytics endpoint and immediately return a response, that POST is a floating promise. The runtime may terminate the isolate before it completes, and your telemetry disappears without any error.

As the authors of Architecting on Cloudflare put it: “Edge observability assumes ephemerality. V8 isolates spin up, handle requests, and vanish. No disk to write to, no process to attach to, no server to access. Logs either stream somewhere in real time or disappear forever.”

Traditional APM agents are built for persistent processes. They instrument code, accumulate metrics in memory, and periodically flush to a backend. None of that works in a serverless environment where the process might not exist 50 milliseconds from now.

waitUntil() bridges this gap. It gives your telemetry sends a window to complete after the response has already shipped to the user. The user sees zero added latency. Your metrics still arrive.

The waitUntil() Execution Lifecycle

Understanding the timeline is critical for instrumenting Cloudflare Workers using waitUntil() semantics correctly.

The Timeline

  1. Request arrives. The isolate spins up (or is reused).
  2. Handler executes. Your code runs, recording metrics, building the response.
  3. Response sent. The client gets its data. Latency stops accruing from the user’s perspective.
  4. waitUntil window opens. Up to 30 seconds for all waitUntil promises to settle.
  5. Isolate torn down. After all promises settle (or 30 seconds expire), the runtime kills the isolate.

That 30-second limit is shared across all waitUntil() calls within the same request. It’s not 30 seconds per call. If your first waitUntil fires at 0 seconds and your second fires at 25 seconds, the second one only gets 5 seconds before cancellation.

Wall Time vs. CPU Time

Wall time in Workers includes the entire lifecycle, from request start through waitUntil completion. Cloudflare’s metrics documentation confirms that wall time “measures the wall time that the JavaScript context remained open, including time spent waiting on I/O, and time spent executing in your Worker’s waitUntil() handler.”

CPU time is different. It only counts actual JavaScript execution, not time spent waiting for I/O. Your telemetry flush sitting in waitUntil while a fetch completes mostly counts as I/O wait time, not CPU time. This is good news: sending metrics in waitUntil has minimal impact on your CPU time quota.

The Spectre Timing Limitation

This one catches people off guard. Due to Spectre attack mitigations, the Workers runtime only updates the clock on I/O events. Any CPU-heavy processing between I/O calls will appear to take 0 milliseconds.

For instrumentation, this means if you’re recording span durations for CPU-bound work, those durations will be wrong. They’ll show 0ms unless there’s an I/O event (like a fetch or KV read) between your timing measurements. This isn’t a bug in your code. It’s a deliberate security constraint in the runtime.

The Flush Pattern for Metrics and Traces

The correct pattern for instrumenting Cloudflare Workers using waitUntil() semantics follows a simple structure: record telemetry during request handling, build the response, then flush all telemetry inside waitUntil before returning.

export default {
  async fetch(request, env, ctx) {
    // Record metrics during request handling
    const start = Date.now();
    const response = await handleRequest(request, env);
    const duration = Date.now() - start;

    metricsClient.record("request_duration", duration, {
      path: new URL(request.url).pathname,
      status: response.status,
    });

    // Flush inside waitUntil — this runs after the response ships
    ctx.waitUntil(metricsClient.flush());

    return response;
  }
};

Why flushAt: 1 and flushInterval: 0

Most telemetry SDKs batch events in memory and flush on a timer or when a batch threshold is reached. This is sensible for long-running servers. It’s disastrous for Workers.

PostHog’s Cloudflare Workers documentation states the pattern directly: set flushAt to 1 and flushInterval to 0 to send captured data without batching, because “Cloudflare Workers can terminate before it’s sent causing data loss.”

This isn’t optional. Any SDK running in a short-lived handler needs to flush immediately, per request. Batching timers won’t fire because the isolate is gone. For a deeper look at configuring these parameters, see configuring buffering and flush behavior.

The Three Rules of Serverless Instrumentation

No existing guide states these cleanly, so here they are:

  1. Flush immediately per request. Never rely on batching timers. The isolate won’t be around when they fire.
  2. Wrap every telemetry send in waitUntil. A fetch to your metrics endpoint that isn’t wrapped is a floating promise. It will be cancelled.
  3. Accept best-effort delivery, or use Queues. waitUntil() ensures completion, not success. If you need guarantees, use a different mechanism.

For practical examples of instrumenting counters and histograms using this pattern, see instrumenting Cloudflare Workers with counters and histograms.

Common Instrumentation Approaches Compared

There are several ways to instrument Cloudflare Workers using waitUntil() semantics, and some don’t need waitUntil at all.

Cloudflare Workers Analytics Engine

Cloudflare’s own Analytics Engine is a binding-based API. You call it synchronously, and it handles delivery internally. No waitUntil needed. However, it’s tied to Cloudflare’s ecosystem and has its own query language. It works well for Cloudflare-native dashboards but doesn’t integrate with external observability stacks.

OpenTelemetry via otel-cf-workers

The community-maintained otel-cf-workers library provides an OpenTelemetry SDK built specifically for the Workers runtime. It auto-instruments fetch calls, KV, Durable Objects, and other bindings. The library handles the waitUntil flush pattern internally.

One thing to keep in mind: the SimpleSpanProcessor with HTTP export adds a fetch call at the end of each request. This runs inside waitUntil, so it doesn’t affect user-facing latency, but it consumes CPU time that counts against your Worker limits.

Cloudflare’s Built-In Tracing

Cloudflare now provides tracing instrumentation out of the box, with no code changes required. You enable tracing on your Worker, and Cloudflare automatically captures telemetry data for fetch calls. Workers supports exporting OpenTelemetry-compliant traces and logs to destinations like Honeycomb, Grafana Cloud, Axiom, and Sentry.

Custom HTTP Telemetry

For lightweight needs, you can POST metrics directly to any HTTP API inside waitUntil. This is the simplest approach and works with any backend that accepts HTTP. The Distlang Metrics API supports this pattern, and the JavaScript client handles the flush mechanics automatically.

Tail Workers

If your primary concern is capturing logs and exceptions, Tail Workers are worth considering. They execute even if your Worker throws an uncaught exception, which waitUntil cannot guarantee. A Tail Worker receives events from your main Worker and can forward them to any logging destination.

Pitfalls and Gotchas

Knowing how to instrument Cloudflare Workers using waitUntil() semantics is only half the battle. The other half is avoiding the mistakes that cause silent failures.

Destructuring ctx Loses the Binding

This one generates “Illegal invocation” errors that confuse people. If you destructure ctx in your handler signature:

// DON'T DO THIS
export default {
  async fetch(request, env, { waitUntil }) {
    waitUntil(somePromise); // Throws "Illegal invocation"
  }
};

The waitUntil function loses its this binding to the context object. You need to call it as ctx.waitUntil(), or use the new module import. Practitioners on Cloudflare’s community forums report this as one of the most common confusion points for developers new to Workers.

waitUntil Ensures Completion, Not Success

As the Inngest blog puts it: “It is a very useful tool and it ensures completion. However, it does not ensure success.” If the promise you pass to waitUntil rejects, that’s it. There’s no automatic retry. The telemetry is lost.

For most metrics and logs, this is acceptable. For audit logs or billing events, it isn’t.

Memory Exhaustion from Telemetry Batching

This is a real-world problem that almost no documentation covers. In a GitHub issue filed against Langfuse, a developer discovered that their Worker was running out of memory because the telemetry SDK was accumulating multiple copies of agent state in memory. After removing Langfuse tracing, the memory issues disappeared. The fix was setting flushAt: 1 and flushInterval: 0.

The lesson: telemetry SDKs that batch in memory can cause out-of-memory crashes in Workers isolates, which have constrained memory budgets. Flushing immediately per request isn’t just a performance recommendation. It’s a stability requirement. For more on batch sizing and payload limits, read about batching and payload size limits.

Floating Promises Mean Silent Data Loss

A promise that isn’t awaited, returned, or passed to waitUntil() is a floating promise. The Workers runtime may terminate the isolate before it completes. There’s no warning, no error, no log entry. The data just vanishes.

This is the single most common cause of dropped telemetry in Workers. Every fetch call that sends data externally must be wrapped in waitUntil.

waitUntil Does Not Extend CPU Time

A common misconception: waitUntil extends the isolate’s lifetime for I/O, but it doesn’t give you more CPU time. If you’re doing heavy computation in a waitUntil callback, you can still hit CPU limits. The 30-second window is primarily for waiting on network requests to complete.

waitUntil() vs. Vercel after() vs. AWS Lambda Extensions

Developers working across multiple serverless platforms need a mental model for how post-response execution works on each.

Feature Cloudflare waitUntil() Vercel after() AWS Lambda Extensions
API shape Promise-based Callback-based Runtime hooks / HTTP
Post-response window Up to 30 seconds Platform-managed Up to 2 seconds (SHUTDOWN phase)
Access pattern ctx.waitUntil(promise) or module import after(callback) External process / internal extension
Failure handling Promise.allSettled semantics Similar Process-level
Best for Metrics, logs, cache writes Metrics, logs, revalidation Telemetry agents, cleanup

Vercel’s after() serves the same purpose as waitUntil: running non-critical work after the response. If you’re building for both platforms, see the Vercel metrics quickstart for the after() equivalent.

AWS Lambda Extensions are a fundamentally different model. They run as separate processes alongside your function, with their own lifecycle hooks. They’re more powerful but significantly more complex to set up and maintain.

When to Use waitUntil() vs. Queues for Telemetry

Not all telemetry has the same reliability requirements. The decision framework is straightforward.

Use waitUntil() when:

  • A dropped metric is tolerable (most request counters, latency histograms, feature flags)
  • You want zero added latency to the response
  • The telemetry is fire-and-forget by nature

Use Cloudflare Queues when:

  • You need guaranteed delivery (billing events, audit logs, compliance data)
  • You need automatic retries on failure
  • The data has business-critical value

Queues add complexity. You need a consumer Worker to process messages, and there’s added latency in the pipeline. But they provide reliable delivery and automatic retries that waitUntil simply cannot offer.

For the vast majority of observability data, waitUntil is the right choice. Metrics and logs are statistical by nature. Losing 0.01% of data points during transient failures doesn’t meaningfully affect your dashboards or alerting.

Putting It All Together

The correct way to instrument Cloudflare Workers using waitUntil() semantics comes down to a few principles applied consistently:

  1. Record during the request. Capture timestamps, counters, and labels as your handler executes.
  2. Build the response first. Don’t let telemetry block the user.
  3. Flush inside waitUntil. Pass the flush promise to ctx.waitUntil() or use the module import.
  4. Disable SDK batching. Set flushAt: 1 and flushInterval: 0 for any telemetry client.
  5. Use the module import for deep call stacks. The import { waitUntil } from 'cloudflare:workers' API eliminates context threading.
  6. Choose the right tool for the reliability level. waitUntil for best-effort metrics, Queues for guaranteed delivery, Tail Workers for exception-safe logging.

The Workers runtime gives you everything you need to build observable edge applications. You just need to work within its lifecycle model rather than against it.

To get from zero to a working metrics dashboard in minutes, the Cloudflare Workers quickstart walks through the full setup with the waitUntil flush pattern built in.


Frequently Asked Questions

What happens if a waitUntil() promise takes longer than 30 seconds?

The Workers runtime cancels it. The 30-second limit applies to the total post-response window, shared across all waitUntil calls in a single request. Any promises that haven’t settled by that point are terminated without warning. For telemetry flushes, this is rarely a problem since a well-configured HTTP POST to a metrics endpoint completes in under a second.

Can I use waitUntil() in Durable Objects?

Durable Objects have a different execution model with longer lifetimes, but they do support ctx.waitUntil() on their fetch handler context. The semantics are similar: the runtime keeps the object alive until the promise settles. However, since Durable Objects are already longer-lived than regular Workers, the urgency around waitUntil for telemetry is somewhat reduced.

Do I need waitUntil() for Cloudflare’s Workers Analytics Engine?

No. Workers Analytics Engine is a binding-based API that handles delivery internally. You call it synchronously during your handler, and Cloudflare takes care of the rest. waitUntil is only necessary when sending data to external services via HTTP.

How does the new import-based waitUntil differ from ctx.waitUntil()?

Functionally, they’re identical. The import { waitUntil } from 'cloudflare:workers' API, introduced in August 2025, simply removes the need to pass the ctx object through your call stack. This is a developer experience improvement, not a behavioral change. Use whichever is more convenient for your code structure.

Why do telemetry SDKs cause memory issues in Workers?

Most telemetry SDKs are designed for long-running servers where batching events in memory and flushing periodically makes sense. In Workers, the isolate’s memory is constrained and the flush timer may never fire. Events accumulate across the request lifecycle (and potentially across reused isolates) until memory is exhausted. The fix is always to set flushAt: 1 and flushInterval: 0 so data ships immediately.

Should I use waitUntil() or Tail Workers for logging?

It depends on your reliability needs. waitUntil is simpler and handles most logging scenarios. But if your Worker throws an uncaught exception, waitUntil callbacks may not execute. Tail Workers always execute, even after an uncaught error, making them the better choice for critical log capture. You can use both: waitUntil for structured metrics and Tail Workers for error logs.

Does waitUntil() add latency to the response?

No. That’s the entire point. Work passed to waitUntil happens after the response has been sent to the client. The user sees no added latency. The only cost is the CPU time and I/O time consumed during the waitUntil window, which counts against your Worker’s resource limits but not against the user’s perceived performance.

Can I call waitUntil() after returning the response?

You need to call ctx.waitUntil() before the handler function returns, even though the promise it wraps will execute after the response is sent. If you’re using the module import (import { waitUntil } from 'cloudflare:workers'), you have more flexibility since you can call it from anywhere during request processing. The key is that the runtime needs to know about the promise before the handler completes.