Buffering and Flush Strategies for Edge Functions: 2026
If you’re building on serverless platforms like Cloudflare Workers or Vercel, you’ve probably noticed something frustrating: your telemetry data vanishes. Metrics never arrive. Traces have gaps. Error reports go missing. The root cause is almost always the same: your edge function terminated before it finished exporting data. Understanding buffering and flush strategies for edge functions is how you fix this.
Explore Distlang Metrics for a serverless-native approach to this problem.
What Buffering and Flushing Mean at the Edge
Buffering in the context of edge functions means holding data points (counters, histogram samples, log entries, trace spans) in memory during a single function invocation rather than sending each one individually over the network. You accumulate data as your handler runs, building up a payload.
Flushing is the act of pushing all that buffered data to an external destination, like a metrics API or a tracing backend, before the runtime terminates. It’s the “make sure this actually gets sent” step.
On a traditional server, these concepts barely register. Your Node.js process runs for hours or days. An APM agent accumulates metrics in the background, flushes on a timer every 30 seconds, and nobody thinks twice. The process stays alive. The data gets sent eventually.
Edge functions don’t work this way. They run in V8 isolates, not long-running processes. The isolate spins up, handles a request, and can be destroyed immediately after sending the response. There’s no disk to write to, no persistent memory between invocations, and no background thread that keeps running after your handler returns.
This is why buffering and flush strategies for edge functions exist as a distinct discipline. The rules that work for traditional servers actively cause data loss at the edge.
Why Edge Functions Make This Hard
The core problem is ephemerality. A Cloudflare Worker or Vercel Edge Function exists only to serve a single request (or a brief burst of requests in the same isolate). Once the response goes out, the platform can kill the isolate at any time.
Several specific constraints compound this:
No persistent process. Traditional APM agents assume they can attach to a long-running Node.js server and accumulate data across thousands of requests. Even though Workers now support a subset of Node.js APIs behind compatibility flags, there is no long-running process to attach to. The fundamental assumption of background, asynchronous export breaks down.
Tight CPU limits. Supabase edge functions, for example, enforce a 2000ms CPU time limit. Cloudflare’s free tier allows just 10ms of CPU time. Every millisecond spent on telemetry export is a millisecond taken from your actual business logic.
Cross-request isolation. On Cloudflare, practitioners in community forums report that implementing a batch collector across requests is surprisingly difficult. One developer discovered that different request contexts can’t manipulate each other’s timeouts or intervals, resulting in errors like “Cannot clear a timeout created in a different request context.” You can communicate between contexts only through simple global values, not promises or streams.
Silent failure mode. When telemetry gets dropped, nothing errors out. Your function returns a 200 to the user. The data simply never arrives at your backend. You don’t know what you don’t know.
The engineering team at Highlight.io documented this problem firsthand while implementing OpenTelemetry at the edge. They found that standard OTEL “doesn’t await its flushed spans,” and as a result, “a majority of our errors never got sent.” That’s not a minor gap. That’s most of your observability data disappearing.
The Batch vs. Immediate Processing Trade-off
This is the core architectural decision when implementing telemetry in serverless environments. OpenTelemetry offers two span processors that represent opposite ends of the spectrum.
SimpleSpanProcessor (Immediate Export)
SimpleSpanProcessor exports each span synchronously as soon as it ends. No batching, no queuing, no background thread. When a span finishes, the export call happens right there on the main thread.
Pros: Reliable in short-lived handlers. Every span gets exported before the function can terminate.
Cons: Each span triggers a network call. For a handler producing five spans, that’s five HTTP requests to your collector. The overhead is roughly 5 to 15 milliseconds per span, depending on network latency between the edge location and your collector.
BatchSpanProcessor (Batched Export)
BatchSpanProcessor buffers completed spans in an in-memory queue and exports them in batches at regular intervals on a background thread. It’s dramatically more efficient for high-volume workloads: a server handling 1,000 requests per second with 5 spans each would make 5,000 synchronous network calls with SimpleSpanProcessor, versus perhaps 10 batch exports with BatchSpanProcessor.
The problem: In serverless environments, batching leads to data loss because the function might be frozen or terminated before the batch flushes. The background thread that BatchSpanProcessor relies on simply doesn’t get a chance to run.
The Verdict for Edge Functions
The default recommendation is clear: use SimpleSpanProcessor (or an equivalent immediate export pattern) for edge functions. Use BatchSpanProcessor only for long-lived servers where the process persists.
But this isn’t a blanket rule. At scale, the overhead of immediate export matters. For guidance on how to handle batching, payload format, and size limits effectively, the key is understanding your volume and choosing accordingly.
For cases where you need batching but can’t afford silent data loss, OpenTelemetry provides forceFlush as an explicit hook. The OTEL specification states that forceFlush “SHOULD only be called in cases where it is absolutely necessary, such as when using some FaaS providers that may suspend the process after an invocation, but before the SpanProcessor exports the completed spans.”
Platform-Specific Flush APIs
Each edge platform provides its own mechanism for extending the function’s lifetime past the response. These APIs are the foundation of any buffering and flush strategy for edge functions.
Cloudflare Workers: ctx.waitUntil()
The primary tool on Cloudflare is ctx.waitUntil(). It accepts a Promise and tells the Workers runtime to keep the isolate alive until that promise settles, even after the response has been sent to the client.
export default {
async fetch(request, env, ctx) {
// Do your work, build the response
const response = new Response("OK");
// Flush telemetry after the response is sent
ctx.waitUntil(flushMetrics());
return response;
}
};
Key details that matter for your flush strategy:
30-second hard limit. The waitUntil() call can extend execution for up to 30 seconds after the response is sent. This limit is shared across all waitUntil() calls within the same request. If any promises haven’t settled after 30 seconds, they’re canceled.
New importable API. As of August 2025, Cloudflare allows importing waitUntil directly from cloudflare:workers, so you can call it from anywhere in your code rather than threading the execution context through your entire call stack.
No delivery guarantee. waitUntil does not ensure the async operation succeeds. If the promise rejects, the function still completes normally. It’s a lifecycle extension, not a reliability mechanism.
When to escalate. If your telemetry work can’t finish within the 30-second window, or if you need guaranteed delivery with retries, send messages to a Cloudflare Queue and process them in a separate consumer Worker.
For a complete walkthrough of instrumenting Workers, see the guide on Cloudflare Workers metrics.
Vercel: after() and waitUntil()
Vercel offers two APIs, and the recommendation depends on your framework version.
after() (Next.js 15.1+). This is the preferred approach for modern Next.js applications. It schedules work to run after the response has been sent or prerender has completed. Under the hood, after() wraps Vercel’s waitUntil(promise) primitive, which extends the lifetime of the serverless invocation until all promises settle.
import { after } from 'next/server';
export async function GET(request) {
after(async () => {
await flushMetrics();
});
return new Response("OK");
}
waitUntil() (non-Next.js or older versions). Functions the same way as Cloudflare’s version conceptually. Vercel’s documentation is direct about this: “If you have work that must complete (such as flushing logs, writing to a database, or updating a cache), wrap it in waitUntil or after.”
One important note: Vercel has deprecated its Edge Functions runtime (as of June 2026), recommending the Node.js runtime instead. This doesn’t eliminate the flush problem since serverless Node.js functions on Vercel still terminate after sending a response. The APIs work the same way. For Vercel-specific setup details, see the Vercel metrics documentation.
OpenTelemetry’s forceFlush
Regardless of platform, if you’re using OpenTelemetry, the forceFlush method on your span processor or exporter is critical. It explicitly triggers export of all buffered data.
// At the end of your handler, before returning
await sdk.tracerProvider.forceFlush();
This applies to Cloudflare Workers, Vercel, AWS Lambda, and any other FaaS environment. The pattern is the same: call forceFlush at the end of every invocation.
Common Pitfalls
These are the mistakes that cause silent data loss. Every one of them has been reported by real developers in production.
Calling shutdown() Instead of forceFlush()
This is the most common and damaging mistake. The recommended approach is to call only forceFlush on your exporter at the end of each invocation. Do not call shutdown on the OpenTelemetry SDK. Calling shutdown tears down the entire telemetry pipeline, which breaks tracing for subsequent warm invocations because the SDK is not re-initialized automatically.
The distinction matters because serverless platforms reuse isolates. Your function may be “warm” and handle multiple requests. Shut down the SDK on the first request, and every subsequent request in that isolate runs blind.
Floating Promises
If you start an async export but don’t pass the promise to waitUntil() or after(), the platform has no reason to keep the isolate alive. The promise gets silently canceled when the function terminates.
This is exactly what happened with early Sentry integrations in serverless. Sentry’s SDK added exceptions to a background queue and POSTed them asynchronously. The serverless function exited before the queue could be flushed. No errors, no warnings. Just missing data.
OTEL’s Batch Processor Dropping Spans
As the Highlight.io team discovered, using BatchSpanProcessor in edge functions causes most of your data to disappear. The processor is designed for long-running processes. It queues spans and exports them on a timer. In an edge function, that timer never fires because the isolate is already gone.
Cross-Request Timer Conflicts on Cloudflare
If you try to implement your own batching logic using setTimeout or setInterval across multiple requests in the same Cloudflare Worker isolate, you’ll hit context isolation issues. Timers created in one request context can’t be cleared from another. This makes DIY batch collection in Workers much harder than it looks.
Practical Flush Patterns
The most reliable pattern for buffering and flush strategies for edge functions follows a simple three-step sequence: record, buffer, flush on handler exit.
The Record, Buffer, Flush Pattern
- Record metrics, spans, or log entries as your handler executes.
- Buffer them in memory using a lightweight client that accumulates data points.
- Flush everything in a single network call at the end of the handler, wrapped in the platform’s lifecycle extension API.
This pattern works across all edge platforms. The only thing that changes is the lifecycle API you wrap the flush call in.
For a deeper look at how to configure this pattern, including buffer sizes and flush timing, see configuring buffering and flush in a JavaScript metrics client.
Try the Distlang Metrics quickstart to see this pattern in action.
Using a Lightweight Metrics Client
Rather than wiring up full OpenTelemetry in an edge function (with its cold start overhead and flush complexity), a lightweight client designed for short-lived handlers simplifies everything. The JavaScript metrics documentation covers how to define metric sets, record counters and histograms, and flush with minimal code.
The key is that the client handles buffering internally and exposes a single flush() call you can pass to waitUntil() or after().
When to Use Each Strategy
| Strategy | Best For | Risk | Platform Example |
|---|---|---|---|
| Immediate/simple export | Low-volume, critical telemetry | 5-15ms overhead per span | SimpleSpanProcessor |
| Buffered with forced flush | Moderate volume, latency-sensitive responses | Must call forceFlush explicitly | OTel forceFlush in any FaaS |
| waitUntil/after background flush | Any volume, non-blocking response | 30s timeout, no delivery guarantee | Cloudflare Workers, Vercel |
| Queue offload | High volume or work exceeding 30s | Added infrastructure complexity | Cloudflare Queues |
For most edge function use cases, the third option (background flush via waitUntil or after) is the right default. It doesn’t block the response, handles moderate data volumes well, and works with any export mechanism. Reserve queue offloading for cases where you need guaranteed delivery or where your telemetry payload is too large for a single flush within the time limit.
For a complete walkthrough from instrumentation to visualization, the end-to-end metrics example shows how quickly you can go from zero to a working dashboard.
Putting It All Together
Buffering and flush strategies for edge functions aren’t optional. They’re the difference between having observability data and flying blind. The ephemeral nature of V8 isolates means that every piece of telemetry you want to keep must be explicitly flushed before the runtime disappears.
The principles are straightforward: prefer immediate or forced-flush export over background batching, always wrap your flush call in the platform’s lifecycle extension API, never call shutdown() when you mean forceFlush(), and don’t let promises float without passing them to waitUntil() or after().
If you’re implementing metrics for Cloudflare Workers or Vercel and want a client that handles these flush semantics out of the box, get started with Distlang Metrics.
FAQ
Why do edge functions lose telemetry data?
Edge functions run in ephemeral V8 isolates that can terminate immediately after sending a response. Any data still buffered in memory at that point is lost. Unlike traditional servers, there’s no persistent background process to eventually flush queued data. You must explicitly export everything before the handler completes or use a lifecycle API like waitUntil() to extend the runtime.
Should I use BatchSpanProcessor or SimpleSpanProcessor in edge functions?
Use SimpleSpanProcessor (or an equivalent immediate export pattern) as the default for edge functions. BatchSpanProcessor relies on a background thread that may never get a chance to run in a short-lived isolate. The trade-off is higher network overhead with SimpleSpanProcessor (roughly 5-15ms per span), but at least your data arrives. Reserve BatchSpanProcessor for long-running servers.
What is the difference between forceFlush() and shutdown() in OpenTelemetry?
forceFlush() exports all buffered data without tearing down the telemetry pipeline. shutdown() exports buffered data and then destroys the SDK internals. In serverless environments where isolates are reused across multiple invocations, calling shutdown() breaks tracing for all subsequent warm requests. Always use forceFlush() at the end of each invocation.
How long does Cloudflare’s waitUntil() keep a Worker alive?
Up to 30 seconds after the response is sent or the client disconnects. This limit is shared across all waitUntil() calls within a single request. If your flush work can’t complete within that window, consider offloading to Cloudflare Queues for reliable, retried processing.
Does Vercel’s after() guarantee my background work completes?
after() extends the function’s lifetime so your async work can run, but it doesn’t guarantee success. If the promise rejects or the platform hits a timeout, the work may not complete. For critical operations, add retry logic or use a queue-based approach.
Can I batch telemetry across multiple requests in the same Cloudflare Worker isolate?
In practice, this is very difficult. Cloudflare enforces cross-request context isolation, which prevents different request contexts from sharing timers, promises, or streams. You can share simple global values, but implementing a proper batch collector that spans multiple requests requires working around significant constraints.
What’s the best flush strategy for high-traffic edge functions?
For high-traffic edge functions, a hybrid approach works best. Buffer data within each invocation using a lightweight client, flush in a single batched network call at the end of the handler (wrapped in waitUntil() or after()), and offload to a queue if the volume exceeds what a single flush can handle within the platform’s time limits.
Is OpenTelemetry the only option for edge function observability?
No. While OpenTelemetry is the most popular open standard, its full SDK adds cold start overhead and flush complexity that may not be worth it for simple metrics use cases. Lightweight, purpose-built clients designed for short-lived handlers can achieve the same results with less configuration. The key requirement is that whatever tool you use must support explicit flushing before the function exits.