Streaming Server-Sent Events from Edge Middleware
This guide is part of Response Streaming and Transformation at the Edge. It covers one endpoint shape end to end: a long-lived text/event-stream response produced inside an edge isolate, framed correctly, kept alive against idle proxies, and torn down the instant the client goes away.
Server-Sent Events is the least glamorous and most appropriate transport for one-way server-to-client updates — job progress, token streams, live counters, notification badges. It is plain HTTP, it survives corporate proxies that block WebSocket upgrades, and the browser’s EventSource reconnects for you. At the edge it is also unusually cheap: an idle stream holds a socket and a closure, not a thread.
The problem
You write the endpoint, it works locally, and then production produces one of four symptoms. Nothing arrives in the browser until the stream ends, at which point every event appears at once. Or events arrive for roughly a minute and then the connection dies and EventSource reconnects, forever, in a loop. Or the stream works but your logs show the producer still running long after the tab was closed, burning CPU and quota on data nobody will receive. Or EventSource fires onerror immediately and never delivers a single message, even though curl shows the right bytes.
Each has a distinct cause, and none of them is a bug in your event-generating logic.
Root cause: three independent contracts, all of them strict
The framing contract. EventSource parses a very specific line-oriented format. A message is one or more field: value lines terminated by a blank line — that is, a \n\n sequence. Miss the second newline and the event is buffered by the parser forever, waiting for a terminator that never comes. That single character explains most “nothing arrives” reports.
The buffering contract. Between your isolate and the browser there may be a CDN tier, a reverse proxy, or a compression layer, and several of these buffer response bodies by default. A proxy that waits for a complete body before forwarding turns a streaming response into a batch response. Headers exist to opt out, and you must send them.
The lifetime contract. An edge invocation has a wall-clock ceiling and, on some platforms, a separate limit on how long a response may remain open. A stream is not exempt. When the ceiling arrives the platform severs the connection, EventSource reconnects, and if your producer restarts from scratch the client sees duplicates. The id: field and the Last-Event-ID request header exist precisely to make that reconnection resumable rather than lossy.
Step 1 — Encode frames, correctly, once
Put the framing in one function and never hand-build a string anywhere else. Multi-line data must be split into one data: line per line, and any newline inside a value must be escaped by that splitting rather than passed through, or the parser will read the remainder as a separate field.
// lib/sse.ts
export interface SseEvent {
data: unknown;
event?: string;
id?: string;
retry?: number;
}
const encoder = new TextEncoder();
export function encodeEvent(e: SseEvent): Uint8Array {
let frame = '';
if (e.id !== undefined) frame += `id: ${e.id}\n`;
if (e.event !== undefined) frame += `event: ${e.event}\n`;
if (e.retry !== undefined) frame += `retry: ${e.retry}\n`;
const body = typeof e.data === 'string' ? e.data : JSON.stringify(e.data);
// One data: line per physical line. A raw \n inside a value would be
// parsed as the start of a new field.
for (const line of body.split('\n')) frame += `data: ${line}\n`;
return encoder.encode(`${frame}\n`); // the trailing \n closes the frame
}
/** A comment line. Keeps the socket warm without dispatching an event. */
export function encodeComment(text = 'ping'): Uint8Array {
return encoder.encode(`: ${text}\n\n`);
}
The comment form — a line beginning with : — is the heartbeat primitive. EventSource ignores it entirely, so it never reaches your listeners, but it is real traffic on the wire, which is all an idle timer cares about.
Step 2 — Build the ReadableStream
ReadableStream gives you a start callback that runs when the consumer begins pulling and a cancel callback that runs when the consumer goes away. Everything about the stream’s lifetime hangs off those two.
// lib/stream.ts
import { encodeComment, encodeEvent, type SseEvent } from './sse';
export interface StreamOptions {
heartbeatMs?: number;
maxDurationMs?: number;
signal?: AbortSignal;
lastEventId?: string | null;
}
export function sseStream(
produce: (
emit: (e: SseEvent) => void,
ctx: { lastEventId: string | null; signal: AbortSignal },
) => Promise<void>,
opts: StreamOptions = {},
): ReadableStream<Uint8Array> {
const heartbeatMs = opts.heartbeatMs ?? 15_000;
const maxDurationMs = opts.maxDurationMs ?? 25_000;
const controller = new AbortController();
if (opts.signal) {
opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
let heartbeat: ReturnType<typeof setInterval> | undefined;
let deadline: ReturnType<typeof setTimeout> | undefined;
return new ReadableStream<Uint8Array>({
async start(c) {
let closed = false;
const safeEnqueue = (bytes: Uint8Array) => {
if (closed) return;
try {
c.enqueue(bytes);
} catch {
closed = true; // consumer detached mid-write
controller.abort();
}
};
// Advertise the reconnect delay up front, then keep the pipe warm.
safeEnqueue(encodeEvent({ event: 'open', data: { ok: true }, retry: 3000 }));
heartbeat = setInterval(() => safeEnqueue(encodeComment()), heartbeatMs);
// Close cleanly before the platform kills us, so the client's
// reconnect is a normal one rather than a transport error.
deadline = setTimeout(() => controller.abort(), maxDurationMs);
try {
await produce(
(e) => safeEnqueue(encodeEvent(e)),
{ lastEventId: opts.lastEventId ?? null, signal: controller.signal },
);
} catch (err) {
safeEnqueue(encodeEvent({ event: 'error', data: { message: 'stream_failed' } }));
console.log(JSON.stringify({ event: 'sse.produce_failed', err: String(err) }));
} finally {
clearInterval(heartbeat);
clearTimeout(deadline);
closed = true;
try { c.close(); } catch { /* already closed */ }
}
},
cancel() {
// Fires when the client disconnects. Stop all work immediately.
clearInterval(heartbeat);
clearTimeout(deadline);
controller.abort();
},
});
}
Two things here are load-bearing. safeEnqueue swallows the throw that enqueue produces when the consumer has already detached — without it, one late write turns into an unhandled rejection that the platform logs as a request error. And the deadline timer aborts before the platform’s own limit, so the client sees an orderly end-of-stream and reconnects on its own schedule instead of treating a severed socket as a failure.
Step 3 — Wire the response and its headers
// middleware.ts (or app/api/events/route.ts)
import { sseStream } from './lib/stream';
export default function middleware(request: Request): Response {
const stream = sseStream(
async (emit, { lastEventId, signal }) => {
let n = lastEventId ? Number(lastEventId) + 1 : 0;
while (!signal.aborted && n < 500) {
emit({ id: String(n), event: 'progress', data: { step: n, pct: n / 5 } });
n += 1;
await sleep(1000, signal);
}
},
{
signal: request.signal, // client disconnect
lastEventId: request.headers.get('last-event-id'),
heartbeatMs: 15_000,
maxDurationMs: 25_000,
},
);
return new Response(stream, {
status: 200,
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-store, no-transform',
'connection': 'keep-alive',
'x-accel-buffering': 'no',
'content-encoding': 'identity',
},
});
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(resolve, ms);
signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
}
Each header earns its place. no-transform tells intermediaries not to recompress or rewrite the body, which is the directive that stops a CDN from applying its own buffering compression layer. x-accel-buffering: no is nginx’s explicit opt-out and is honoured by a surprising number of proxies that inherited nginx conventions. content-encoding: identity stops gzip from being negotiated; a gzip stream can legitimately hold bytes back until its internal buffer fills, which is indistinguishable from a hung endpoint. And the charset=utf-8 on the content type prevents a downstream sniffing layer from guessing wrong on non-ASCII payloads.
Scope the matcher so the streaming path is not intercepted by unrelated middleware:
export const config = {
runtime: 'edge',
matcher: ['/api/events/:path*'],
};
Step 4 — Tie cancellation to the client, not to a timer
A closed tab does not tell your code to stop. What it does is close the TCP connection, which the runtime surfaces two ways: the stream’s cancel callback fires, and request.signal aborts. Wire both to the same AbortController and make every awaitable in the producer honour that signal.
The rule of thumb is that no await in a producer should be able to outlive the connection. A setTimeout-backed sleep must clear its timer on abort. A fetch to an upstream must be passed { signal } so it is cancelled rather than completing into a void. A KV or database read that cannot take a signal should at least be followed by an if (signal.aborted) return; guard so you stop before the next iteration.
Without this, a stream that a user abandoned after two seconds keeps running for its full 25-second budget, doing upstream work and billing CPU for output that is discarded. At any real concurrency that is the dominant cost of an SSE endpoint.
Wall-clock limits per provider
Streaming does not exempt you from invocation limits, and the limit that matters for SSE is wall clock, not CPU. An idle stream consumes almost no CPU, so a CPU-time budget is rarely the binding constraint — the connection ceiling is.
| Provider | Wall-clock ceiling for a streamed response | CPU accounting | Practical SSE guidance |
|---|---|---|---|
| Cloudflare Workers | No fixed response duration limit on paid plans; connections are bounded in practice by client and network conditions | CPU time is metered separately (50 ms default, configurable up to 5 minutes); idle waiting does not accrue | The most permissive host. Still cap sessions explicitly so a leaked stream cannot live indefinitely |
| Vercel Edge Middleware | Streaming responses are limited to roughly 25 seconds of initial response time on standard configurations | Metered by execution duration | Set maxDurationMs to about 20 s and rely on EventSource reconnect with Last-Event-ID |
| Netlify Edge Functions | Around 40 seconds for a streamed response before the platform terminates it | Wall-clock bounded per invocation | Cap at roughly 30 s and make every event carry an id |
Because two of the three force reconnection, resumability is not optional. Emit an id: on every event, read Last-Event-ID on the way in, and have the producer resume from that point. A client that reconnects and receives the same first ten events again is a correctness bug that will look like a rendering glitch.
For the underlying isolate constraints, see memory and CPU limits across edge providers.
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
| Intermediate buffering | None — you talk to the dev server directly | A CDN tier or proxy may buffer unless no-transform and x-accel-buffering are set |
| Compression | Usually off | accept-encoding negotiation may add gzip and hold bytes back |
| Idle timeouts | Effectively absent | 30-120 s at various hops; the shortest one wins |
| Wall-clock limit | Unbounded | 25-40 s on Vercel and Netlify |
| Disconnect detection | Immediate and reliable | Can lag by seconds; guard on signal.aborted each iteration |
| Concurrent streams | One or two, from your browser | Thousands, each holding a socket |
| HTTP version | Often HTTP/1.1 | HTTP/2 or HTTP/3, where per-connection stream limits apply |
The last row bites teams that open many EventSource connections from one page. Under HTTP/1.1 browsers cap concurrent connections per host at six; under HTTP/2 they multiplex, so the same code behaves very differently in the two environments. One stream per tab, multiplexed by event name, is the design that works everywhere.
Validating with Vitest
Test the encoder as pure functions and the stream by actually reading it. Neither needs a server.
// sse.test.ts
import { describe, expect, it, vi } from 'vitest';
import { encodeComment, encodeEvent } from './lib/sse';
import { sseStream } from './lib/stream';
const decoder = new TextDecoder();
/** Drain a stream to a string, stopping after `limit` chunks. */
async function drain(stream: ReadableStream<Uint8Array>, limit = 50): Promise<string> {
const reader = stream.getReader();
let out = '';
for (let i = 0; i < limit; i++) {
const { value, done } = await reader.read();
if (done) break;
out += decoder.decode(value);
}
reader.releaseLock();
return out;
}
describe('encodeEvent', () => {
it('terminates every frame with a blank line', () => {
const frame = decoder.decode(encodeEvent({ data: 'hi' }));
expect(frame).toBe('data: hi\n\n');
});
it('emits one data line per physical line', () => {
const frame = decoder.decode(encodeEvent({ data: 'a\nb' }));
expect(frame).toBe('data: a\ndata: b\n\n');
});
it('orders id, event and retry before data', () => {
const frame = decoder.decode(
encodeEvent({ id: '7', event: 'progress', retry: 3000, data: { pct: 1 } }),
);
expect(frame).toBe('id: 7\nevent: progress\nretry: 3000\ndata: {"pct":1}\n\n');
});
it('encodes a heartbeat as an ignorable comment', () => {
expect(decoder.decode(encodeComment())).toBe(': ping\n\n');
});
});
describe('sseStream', () => {
it('streams events and closes cleanly', async () => {
const stream = sseStream(async (emit) => {
emit({ id: '1', event: 'progress', data: { pct: 50 } });
emit({ id: '2', event: 'progress', data: { pct: 100 } });
});
const text = await drain(stream);
expect(text).toContain('event: open');
expect(text).toContain('id: 1\nevent: progress\ndata: {"pct":50}\n\n');
expect(text.endsWith('\n\n')).toBe(true);
});
it('resumes from Last-Event-ID', async () => {
const seen: string[] = [];
const stream = sseStream(
async (emit, { lastEventId }) => {
seen.push(String(lastEventId));
emit({ id: '43', data: 'next' });
},
{ lastEventId: '42' },
);
await drain(stream);
expect(seen).toEqual(['42']);
});
it('aborts the producer when the consumer cancels', async () => {
let aborted = false;
const stream = sseStream(
async (emit, { signal }) => {
signal.addEventListener('abort', () => { aborted = true; });
await new Promise<void>((r) => signal.addEventListener('abort', () => r(), { once: true }));
},
{ heartbeatMs: 5 },
);
const reader = stream.getReader();
await reader.read(); // consume the open event
await reader.cancel(); // simulate the client going away
expect(aborted).toBe(true);
});
it('emits heartbeats while the producer is idle', async () => {
vi.useFakeTimers();
const stream = sseStream(
async (_emit, { signal }) => {
await new Promise<void>((r) => signal.addEventListener('abort', () => r(), { once: true }));
},
{ heartbeatMs: 100, maxDurationMs: 350 },
);
const read = drain(stream);
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
expect((await read).match(/: ping/g)?.length).toBeGreaterThanOrEqual(3);
});
});
The cancel test is the one worth keeping honest. It asserts the behaviour that costs real money in production — that an abandoned connection stops the producer — and it is the only one that would still pass if you deleted the cancel callback and never noticed.
Common pitfalls and resolutions
Nothing arrives until the stream ends — either the frames are missing their blank-line terminator, or a proxy is buffering. Check the raw bytes with curl -N first; if the bytes are right, the problem is downstream and the fix is cache-control: no-transform, x-accel-buffering: no and content-encoding: identity.
The connection drops every 30 to 60 seconds — an idle timeout somewhere in the path. Add a comment heartbeat at well under the shortest timeout; 15 seconds is a safe default.
EventSource reconnects in a loop and the client sees duplicates — the platform’s wall-clock limit is being hit and the producer restarts from zero. Emit an id: on every event, read Last-Event-ID, and resume.
Listeners never fire although data is arriving — the events carry a custom event: name and the client is listening on onmessage, which only receives events named message. Use addEventListener with the matching name.
CPU or invocation cost far exceeds the number of active users — cancellation is not wired, so abandoned streams run to their full budget. Pass request.signal through and guard every loop iteration.
Unhandled rejection on the request after the client leaves — an enqueue after the consumer detached. Wrap enqueues and treat the throw as a close.
The endpoint is being cached — a shared cache stored the text/event-stream response. Set no-store and never route the path through a caching tier.
Production deployment checklist
Frequently Asked Questions
Why does my SSE endpoint deliver nothing until it finishes?
There are two common causes. Either the frames are missing the blank line that terminates an event, in which case the browser parser buffers them forever, or an intermediary is buffering the whole body. Check the raw bytes with curl using the no-buffer flag first; if the bytes look right, set cache-control no-transform, x-accel-buffering no and content-encoding identity.
What exactly terminates a server-sent event?
A blank line. An event is one or more field and value lines followed by an empty line, which means the byte sequence newline newline. The id, event and retry fields are all optional, but the terminator is not, and it is the only thing that causes the browser to dispatch the event to your listeners.
How often should I send heartbeats?
Well under the shortest idle timeout anywhere in the path between your isolate and the browser, which in practice means fifteen seconds. Send them as comment lines beginning with a colon, which EventSource ignores completely but which still count as traffic to every idle timer in between.
How do I stop work when the user closes the tab?
Link both the request abort signal and the stream cancel callback to a single AbortController, and make every await inside the producer honour that signal. A sleep must clear its timer on abort, an upstream fetch must be passed the signal, and each loop iteration should check whether the signal has already aborted before doing more work.
What happens when the provider wall-clock limit is reached?
The platform severs the connection and EventSource reconnects automatically. That is only safe if your stream is resumable, so emit an id on every event, read the Last-Event-ID request header on the way in, and continue from there. Closing the stream yourself slightly before the platform limit turns an abrupt transport error into an orderly reconnect.
Should I use Server-Sent Events or WebSockets at the edge?
Use Server-Sent Events when the traffic is one way from server to client. It is plain HTTP, it passes through proxies that block upgrade handshakes, the browser reconnects for you, and an idle stream costs a socket rather than a thread. Reach for WebSockets only when the client genuinely needs to send messages on the same connection.