Buffering and Batching Strategies for Short-Lived Handlers
What Are Buffering and Batching?
These two terms get used interchangeably in marketing copy and documentation, but they describe different operations that work together.
Buffering is accumulating data points (metric readings, log lines, trace spans) in an in-memory structure during handler execution instead of sending each one over the network immediately. Think of it as a holding area. A StatsD server, for example, aggregates metrics in memory and flushes them to a backend at regular intervals.
Batching is the act of grouping buffered data into discrete dispatch units, a single HTTP request containing many data points rather than one request per data point. Research on serverless cost optimization has shown that batching several requests together increases the input dimension for parallelization, and a few large batched requests result in lower cost than many small individual ones.
The relationship is sequential: you buffer first, then batch the buffered contents for transmission. A few related terms worth knowing:
- Flush: the act of draining the buffer by dispatching its contents
- Drain: emptying all remaining buffered data, typically at shutdown
- In-band: flushing within the handler’s own execution lifecycle
- Out-of-band: offloading data to an external system (a queue, a separate worker) for later processing
Understanding these distinctions is the foundation for choosing the right strategy. For a broader look at the runtime model that creates these constraints, see what serverless actually means.
Why Short-Lived Handlers Make This Hard
On a traditional long-running server, batching works beautifully. A background thread accumulates spans or metrics, a timer fires every few seconds, and the batch gets shipped. The process stays alive between requests, so the timer always gets its chance to run.
Short-lived handlers break this assumption completely.
Serverless functions are triggered by events (HTTP requests, database changes, file uploads), process one event, return a response, and the runtime may immediately freeze or terminate the execution context. As Vercel’s documentation makes explicit: serverless functions normally terminate the moment a response is sent, and any async work still running gets killed.
This creates a specific, painful failure mode: you buffer data during request handling, intend to flush it later, and the runtime shuts down before the flush happens. The data vanishes. You won’t get an error. You won’t get a warning. You’ll just have gaps in your metrics and missing spans in your traces.
The OpenTelemetry community has documented this problem clearly. AWS Lambda functions can freeze or terminate immediately after the handler returns. If you use BatchSpanProcessor, spans sitting in the queue may never get exported because the background flush thread never gets a chance to run. The same pattern applies to any buffered telemetry on any serverless platform.
Prometheus developers identified this as a fundamental architectural gap years ago, noting that in “true FaaS,” function calls are lightweight and the additional overhead of sending anything over the network on every invocation defeats the purpose of that lightweight model.
So developers face a tension: sending data immediately is expensive and slow, but buffering risks losing everything.
In-Handler Flush Strategies
Every major serverless platform now provides a lifecycle hook that extends execution past the response. These hooks are the primary tool for buffering and batching strategies for short-lived handlers.
Cloudflare Workers: ctx.waitUntil()
Cloudflare Workers offer ctx.waitUntil(), which accepts a Promise and keeps the Worker alive even after the response has been sent. This is the right place to flush buffers.
export default {
async fetch(request, env, ctx) {
// Buffer metrics during request handling
recordMetric('request_count', 1);
const response = new Response('OK');
// Flush after response is sent
ctx.waitUntil(flushMetrics());
return response;
}
};
The critical constraint: the Worker’s lifetime is extended for up to 30 seconds after the response is sent. This 30-second window is shared across all waitUntil() calls within the same request. If any promises haven’t settled after 30 seconds, they’re cancelled. Think of this as your “flush budget,” the total time available to drain all buffers.
One trap that catches developers: floating promises. A Promise that is not awaited, returned, or passed to ctx.waitUntil() is a floating promise. Cloudflare’s best practices guide warns that floating promises cause silent bugs including dropped results, swallowed errors, and unfinished work. The Workers runtime may terminate your isolate before a floating promise completes. Every async flush call must go through waitUntil().
For a walkthrough of recording counters and histograms on Workers using this pattern, see instrumenting Cloudflare Workers with counters and histograms.
Vercel / Next.js: after() and waitUntil()
Starting with Next.js 15.1, the after() API lets you schedule work after the response finishes. Under the hood, Vercel uses a waitUntil(promise) primitive that extends the serverless invocation lifetime until all passed promises settle.
Practitioners on forums and GitHub discussions frequently report that metrics aren’t properly reported from Route Handlers because the handler completes execution before the metrics have a chance to flush. The fix is calling forceFlush() inside after():
import { after } from 'next/server';
import { meterProvider } from './metrics';
export async function GET() {
recordMetric('page_view', 1);
after(async () => {
await meterProvider.forceFlush();
});
return Response.json({ ok: true });
}
This pattern, buffer during the request, flush inside the lifecycle hook, return the response fast, is the standard approach for in-handler flushing across platforms. The Vercel metrics integration guide walks through this setup in detail.
AWS Lambda: Extensions and Explicit Flush
Lambda doesn’t have a direct waitUntil() equivalent in the handler itself. Instead, you have two options:
- Call
forceFlush()before the handler returns. This blocks the response slightly but guarantees delivery. - Use Lambda Extensions, which run as a separate process alongside the function and get their own lifecycle hooks, including a shutdown phase where they can drain buffers.
The trade-off is clear. Option 1 adds latency to every response. Option 2 adds cold start overhead and architectural complexity. Most teams start with option 1 and move to extensions only when the latency cost becomes unacceptable.
Out-of-Band Batching Strategies
In-handler flushing works for best-effort telemetry, but it has limits. If you need guaranteed delivery, retry semantics, or rate limiting against downstream APIs, you need to move the batching out of the handler entirely.
Cloudflare Queues
Cloudflare Queues provide a durable buffer between your handler and whatever consumes the data. The handler writes a message to the queue (fast, reliable), and a separate consumer Worker processes messages in batches.
Queues expose two configuration knobs that directly control batching behavior:
max_batch_size: the maximum number of messages delivered to a consumer at once (default: 10)max_batch_timeout: the maximum time the queue waits before delivering a batch (default: 5 seconds)
Both limits work together. Whichever is reached first triggers delivery. Consumer concurrency auto-scales horizontally to keep up with write volume.
This pattern is particularly underexplored for metrics. Instead of flushing metrics directly from the handler to a remote collector, you write one message per request to a Queue. The consumer Worker batches those messages and ships them to your metrics backend. You get guaranteed delivery, automatic retries, and the handler stays fast.
Cloudflare’s own guidance is direct: if you need to guarantee that work completes successfully, send messages to a Queue and process them in a separate consumer Worker.
AWS SQS + Lambda Consumer
The same pattern works on AWS. Your handler writes a message to SQS, and a separate Lambda function consumes messages in batches. Prometheus community contributors proposed exactly this approach: publish to a local serverless queue like SQS and have a separate reader push metrics, rather than attempting in-process flushing.
Durable Objects for Stateful Accumulation
For scenarios where you need to aggregate data across multiple requests before dispatching (computing running averages, deduplicating events), Cloudflare Durable Objects provide in-memory state that persists across requests to the same object. They can accumulate data from many handler invocations and flush on a timer or when a threshold is reached.
When to Choose Queues Over In-Handler Flush
Use out-of-band batching when:
- Data loss is unacceptable (payment events, audit logs)
- You need retry semantics for failed deliveries
- You’re rate-limited by a downstream API
- The flush operation itself is expensive or slow
- You want to decouple handler latency from telemetry infrastructure health
The SimpleSpanProcessor vs. BatchSpanProcessor Decision
This is the OpenTelemetry-specific version of the buffering and batching problem for short-lived handlers, and it’s where developers most often get confused.
SimpleSpanProcessor exports each span immediately when it finishes. Higher network overhead, but no risk of data loss from termination.
BatchSpanProcessor buffers spans and exports them periodically (default max queue: 2,048 spans, default schedule delay: 5 seconds). Lower overhead per span, but spans sitting in the buffer when the function terminates are lost forever.
The OTel Java documentation actually warns against using Simple in production: “This processor will cause all spans to be exported directly as they finish, meaning each export request will have a single span. Most backends will not perform well with a single span per request.” But practitioners consistently recommend Simple for truly short-lived functions because the alternative is losing data entirely.
Real-world overhead measurements put the SimpleSpanProcessor with HTTP export at roughly 5 to 15 milliseconds per span, depending on network latency between the edge location and your collector.
The Hybrid Approach
The best pattern for moderate-volume handlers combines both: use BatchSpanProcessor for efficiency, but call forceFlush() explicitly inside your waitUntil() or after() hook. This gets batching’s lower overhead during execution while guaranteeing the buffer drains before termination.
ctx.waitUntil(
spanProcessor.forceFlush()
);
This hybrid approach works because the lifecycle hook gives you a known window to complete the flush. You’re not relying on a background timer that might never fire. You’re explicitly triggering the drain at the right moment.
For teams that want metrics without wiring up OTel processors manually, purpose-built serverless metrics clients handle internal buffering and explicit flush semantics out of the box.
Choosing the Right Strategy
The right approach for buffering and batching in short-lived handlers depends on three factors: how critical the data is, how much latency you can tolerate, and which platform you’re on.
| Scenario | Strategy | Platform Hook | Trade-off |
|---|---|---|---|
| Low-criticality analytics | In-handler buffer + flush via lifecycle hook | waitUntil() / after() |
Small risk of loss if flush exceeds time budget |
| High-criticality events | Out-of-band queue | Cloudflare Queues / SQS | Additional infrastructure, higher reliability |
| OTel traces (low volume) | SimpleSpanProcessor | Any | 5-15ms per span overhead |
| OTel traces (high volume) | BatchSpanProcessor + forceFlush() | waitUntil() / after() |
Must ensure flush completes within time budget |
| Metrics recording | Lightweight client with flush-on-end | waitUntil() / after() |
Minimal overhead, depends on client implementation |
| Durable aggregation | Durable Objects or external state | N/A | Complexity, cost of state management |
The “flush budget” concept helps frame the decision. On Cloudflare Workers, you have 30 seconds post-response. On Vercel, you’re bound by maxDuration. On Lambda, the remaining function timeout applies. If your flush can complete well within that budget, in-handler strategies work. If it can’t, or if the data is too important to risk, use a queue.
Distlang Metrics is built specifically for this use case: an API-first serverless metrics service with a lightweight JavaScript client that handles buffering internally and flushes within platform lifecycle hooks. No agents, no pipelines, no time-series database to manage. If you’re looking for a simple path from code to dashboard, the metrics quickstart guide shows the full setup.
Common Pitfalls
Floating promises. The most common mistake on Cloudflare Workers. If you call an async function without passing the resulting promise to ctx.waitUntil(), the runtime can kill the isolate before it completes. Every async flush must be explicitly registered.
Assuming batch processors will flush before termination. They won’t. The background timer in a BatchSpanProcessor or a StatsD aggregator has no awareness of the handler lifecycle. In-memory aggregation leads to data loss in ephemeral functions, as multiple practitioners and library authors have documented.
Over-buffering in memory. Workers have a 128 MB memory limit. If you’re buffering high-cardinality data or large payloads, you can hit that ceiling before you get a chance to flush. Keep buffer sizes bounded.
Not testing flush timing in production. Local development environments (including Cloudflare’s local workerd runtime) don’t always replicate production termination behavior. A flush that completes in 50ms locally might take 500ms when hitting a remote collector from an edge location. Test with realistic network conditions.
Ignoring cold start impact. SimpleSpanProcessor adds overhead per span. On a cold start, that overhead compounds with initialization time. If cold start latency matters, batch processing with explicit flush is the better path.
Using the wrong metric type. Datadog’s guidance is relevant here: distribution metric types are specifically designed for serverless applications and are more resilient to data loss and function termination than gauge or count types that depend on regular flush intervals.
For a complete working example that ties these patterns together, from instrumentation to a live dashboard, see the end-to-end metrics example.
Frequently Asked Questions
What’s the difference between buffering and batching in serverless?
Buffering is holding data in memory during execution. Batching is grouping that buffered data into a single network request for dispatch. You buffer first, then batch the buffer contents when it’s time to flush. They’re sequential steps in the same data pipeline.
Will I lose metrics if my serverless function terminates before flushing?
Yes. If your buffer hasn’t been drained when the runtime kills the process, that data is gone. There’s no error, no retry, just missing data. This is why lifecycle hooks like ctx.waitUntil() and after() exist: they give you a post-response window to complete the flush.
Should I use SimpleSpanProcessor or BatchSpanProcessor in serverless?
For very short-lived functions with low span volume, SimpleSpanProcessor is safer because it exports immediately. For moderate-volume handlers, use BatchSpanProcessor with an explicit forceFlush() call inside your platform’s lifecycle hook. This gives you batching efficiency without data loss. Expect roughly 5 to 15 milliseconds of overhead per span with the Simple approach.
How long does ctx.waitUntil() keep a Cloudflare Worker alive?
Up to 30 seconds after the response is sent. This time limit is shared across all waitUntil() calls in the same request. Any promises still pending after 30 seconds are cancelled.
When should I use a message queue instead of in-handler flushing?
When data loss is unacceptable, when you need retry semantics, when your downstream API has rate limits, or when the flush operation is too slow to fit within the post-response time budget. Cloudflare Queues and AWS SQS both provide durable buffering with automatic batching and retries.
What is a floating promise and why does it cause data loss?
A floating promise is an async operation that isn’t awaited, returned, or passed to ctx.waitUntil(). The Workers runtime doesn’t track it, so the isolate may terminate before it completes. Every async flush call in a Worker must go through waitUntil() to avoid this.
Can I use Cloudflare Queues as a metrics buffer?
Yes, and it’s an underexplored pattern. Your handler writes one message per request to a Queue. A consumer Worker receives messages in batches (default: 10 messages or every 5 seconds, whichever comes first) and ships them to your metrics backend. This decouples handler performance from metrics infrastructure and provides guaranteed delivery.
How do I get started with metrics in short-lived handlers without setting up Prometheus or Grafana?
API-first metrics services like Distlang Metrics let you record counters and histograms from handler code using a lightweight client, flush via waitUntil() or after(), and view results on an auto-generated dashboard. No agents, no pipelines, no self-hosted infrastructure. The quickstart covers setup in minutes.