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.

Anatomy of an SSE frame A single event is shown line by line with its id, event, data and retry fields annotated. The trailing blank line is highlighted as the terminator that makes the parser dispatch the event. id: 42 event: progress data: {"pct":40} retry: 3000 (blank line) resumes via Last-Event-ID names the listener; default is message repeat for multi-line payloads reconnect delay in milliseconds without this the parser never dispatches
Every field is optional except the terminator; the blank line is the only thing that actually causes an event to fire.

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*'],
};
Heartbeats against an idle proxy timer Two timelines run across sixty seconds. Without heartbeats a long gap between events crosses the proxy idle threshold and the connection is dropped. With heartbeats every fifteen seconds the idle timer never reaches its limit. No heartbeat events silent for 60 s dropped 15 s heartbeat colon-prefixed comments, ignored by EventSource application event heartbeat comment idle timer expiry Set the heartbeat well under the shortest idle timeout in the path, not just under it
The idle timer counts bytes, not events, which is why a comment line nobody consumes is enough to keep the connection alive.

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.

Disconnect to teardown sequence Four participants are shown as vertical lanes. When the browser closes the connection the runtime fires the stream cancel callback and aborts the request signal, the heartbeat interval is cleared, and the producer exits before issuing its next upstream call. Browser Runtime Stream Producer event id 7 delivered tab closed, socket FIN cancel() fires controller.abort() sleep resolves early loop exits, no fetch interval + deadline timers cleared
The disconnect signal is the only thing standing between an abandoned tab and a full budget of wasted upstream work.

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.