Beta · This site is in preview and not yet officially launched.
Tech33 min read

Server-Sent Events vs WebSockets in 2026: when SSE wins, backpressure, reconnect semantics

A streaming LLM endpoint that worked on Chrome failed silently on Safari at 30 KB/s. The SSE vs WebSocket decision and the backpressure, reconnect, and heartbeat patterns that survive production.

BIM Mobius
with DTDucas

A streaming LLM endpoint that worked perfectly on Chrome failed silently on Safari at a sustained throughput of 30 KB/s. No error event. No closed connection. The browser's network panel showed the request as open and receiving data. The UI showed nothing. The on-call engineer spent three hours ruling out the model API, the backend proxy, the CDN, and a race condition in the React state update before finding the actual cause: Safari's Back/Forward Cache was holding the page in a suspended state while the EventSource connection was technically still open, and the server's write buffer had grown to several megabytes before TCP finally applied back-pressure and forced the stream to stall. By the time the page was unfrozen, the buffered events arrived in a burst that the client-side parser could not reconstruct into coherent text chunks.

This is a backpressure failure. It is also an SSE-specific failure. And it is the kind of failure that does not appear in the "SSE vs WebSocket" tutorials, because those tutorials stop at the protocol comparison and never get into what happens when the network or the client slows down.

In 2026 this choice matters more than it ever has. LLM streaming is now a standard feature expectation in developer tools, AI assistants, search interfaces, and observability dashboards. Every major model API — including OpenAI, Anthropic, Mistral, and Groq — delivers streamed completions over SSE. At the same time, collaborative editing, multiplayer features, and financial data feeds have pushed WebSocket maturity to a level where the operational cost of running it is no longer a serious objection for most teams. The two protocols serve overlapping but distinct use cases, and picking the wrong one does not just create technical debt — it creates the kind of silent failure described above.

This post covers the full decision surface: the protocol mechanics, where each transport wins and loses, backpressure semantics in both directions, reconnect contract differences, proxy and edge configuration, and the specific Safari/BFCache failure mode that triggered this investigation. The code samples are TypeScript throughout. The patterns apply to any Node.js-compatible runtime.

What's actually happening

The protocols under the hood

Server-Sent Events are defined in the WHATWG HTML Living Standard, not in a standalone RFC. They operate over a plain HTTP connection with Content-Type: text/event-stream. The server writes UTF-8 encoded lines in a structured format; the browser's built-in EventSource API parses them. The format is deliberately minimal:

id: 42
event: token
data: {"delta":"Hello"}
 
id: 43
event: token
data: {"delta":" world"}
 
: heartbeat comment — ignored by the parser
 
retry: 3000
data: reconnect timer set to 3 seconds
 

Each field is a fieldname: value line. An empty line terminates the event and dispatches it to the client. Lines beginning with : are comments — the only mechanism for heartbeats. The four defined fields are data, event, id, and retry. Unknown fields are ignored, which means the format is extensible without breaking deployed clients.

The id field is the key to SSE's reconnect semantics. When the browser's EventSource reconnects after a dropped connection, it automatically includes a Last-Event-ID HTTP request header containing the value of the last id field it successfully received. The server reads this header and resumes the stream from that point. This is specified behavior — the browser does it without any application code. The reconnect delay is controlled by the retry field, which takes a value in milliseconds. The spec does not mandate a default; browsers implement their own default, typically around 3 seconds in practice, though this is implementation-defined.

RFC 6455, the WebSocket specification, is a fundamentally different contract. A WebSocket connection begins as an HTTP/1.1 request with two specific headers:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

The server responds with 101 Switching Protocols. From that point, the HTTP protocol is gone — the TCP connection carries WebSocket frames directly. A WebSocket frame has a 2-byte header for payloads up to 125 bytes (a single flag byte plus a 7-bit length), compared to hundreds of bytes of HTTP headers on every SSE reconnect. For small, frequent messages this per-message overhead difference is measurable.

WebSocket frames carry both text and binary payloads, supports fragmentation for large messages, and defines native ping/pong control frames for keepalive — the server sends a ping (opcode 0x9), the client must reply with a pong (opcode 0xA). This is built into the protocol; SSE heartbeats require the server to write comment lines and the client to infer liveness from their presence, which is less precise.

The critical asymmetry: WebSocket has no built-in reconnect. The RFC does not specify reconnection behavior at all. Every WebSocket client that needs reconnect — which is every production WebSocket client — must implement its own strategy. SSE reconnect is automatic and standardized. This is not a minor implementation detail; it is a significant difference in production operational burden.

HTTP/2 and HTTP/3: SSE benefits, WebSocket complexity

Under HTTP/1.1, browsers enforce a connection limit of 6 simultaneous connections per origin (this limit is marked "Won't fix" in both Chrome and Firefox). An SSE connection occupies one of those six slots for its entire lifetime. Open two SSE connections to the same origin from two tabs and you have consumed a third of your browser's budget for that domain. Open six tabs with SSE and every background resource request stalls.

HTTP/2 eliminates this problem. HTTP/2 multiplexes multiple logical streams over a single TCP connection, with the server advertising the maximum number of concurrent streams via the SETTINGS_MAX_CONCURRENT_STREAMS parameter (browsers typically negotiate up to 100 or 1000). An SSE connection over HTTP/2 is a single HTTP/2 stream — it does not occupy a separate TCP connection at all. Six tabs with SSE streams cost six HTTP/2 streams on one TCP connection, not six TCP connections.

WebSocket under HTTP/2 is more complicated. RFC 8441 specifies how to bootstrap a WebSocket connection over an HTTP/2 stream using the extended CONNECT method with a new :protocol pseudo-header:

:method = CONNECT
:protocol = websocket
:scheme = https
:path = /chat
:authority = server.example.com
sec-websocket-version = 13

The SETTINGS_ENABLE_CONNECT_PROTOCOL setting must be advertised by the server before the client can use this. Importantly, RFC 8441 drops the Sec-WebSocket-Key/Sec-WebSocket-Accept handshake that HTTP/1.1 WebSocket requires — the HTTP/2 framing provides equivalent integrity. Browser support for WebSocket over HTTP/2 (RFC 8441) has improved but remains uneven as of 2026, and many load balancers and proxies still route WebSocket connections through HTTP/1.1 upgrade even when HTTP/2 is otherwise available.

The practical upshot: if your deployment serves HTTP/2 to end clients, SSE gets the full multiplexing benefit automatically and transparently. WebSocket over HTTP/2 requires explicit server and browser support for RFC 8441, correct proxy configuration, and more testing. For most teams SSE over HTTP/2 is the path of least resistance.

Backpressure: the asymmetry that matters most

This is where the two protocols diverge most sharply in production, and where most "comparison" articles are silent.

WebSocket has explicit backpressure signals at the JavaScript layer. The bufferedAmount property on a WebSocket instance returns the number of bytes queued for transmission via send() but not yet written to the network. This is a real-time, readable signal:

const ws = new WebSocket('wss://api.example.com/stream');
 
function sendIfCapacity(data: string): boolean {
  const THRESHOLD = 16 * 1024; // 16 KB
  if (ws.bufferedAmount < THRESHOLD) {
    ws.send(data);
    return true;
  }
  return false; // caller decides whether to queue or drop
}

On the server side, the Node.js ws library surfaces a similar signal: socket.bufferedAmount (via the underlying net.Socket) tells you how much data is queued for a specific connection. A server that fans out events to hundreds of connections can check this per-connection and apply selective back-pressure — slowing the rate of writes to slow clients without affecting fast clients.

SSE has no equivalent mechanism at the browser layer. The browser's EventSource has no bufferedAmount property. It has no way to signal to the server that it is falling behind. The browser simply reads from the TCP buffer as fast as it can, and if the server writes faster than the browser reads, the TCP receive buffer fills, TCP advertises a zero window to the sender, and the sender's write stalls at the TCP level. This is a brutal, all-or-nothing mechanism: the entire TCP connection stalls, which affects all concurrent streams on that connection under HTTP/2.

The server-side consequence: for SSE endpoints, you must throttle the write rate yourself. There is no client signal to react to. Your options are:

  1. Rate-cap the write rate based on the known capacity of the client tier (e.g., write no faster than the LLM generates tokens, which is typically 20–80 tokens/second — well within any network's capacity).
  2. Set a write timeout and close the stream if a write does not complete within it, forcing the client to reconnect and resume from Last-Event-ID.
  3. Drop non-critical events (heartbeats, intermediate progress updates) under buffer pressure, keeping only semantically essential events.

For LLM streaming specifically, the token generation rate is the natural throttle. Anthropic's API, for example, dispatches content_block_delta events containing text_delta payloads as tokens are generated — typically 20–80 events per second at full speed. This rate is manageable for any reasonably connected client. The problem appears when the LLM generates a long completion and the client's network is slow or the client tab is backgrounded (BFCache).

The Safari BFCache failure mode

Back/Forward Cache (BFCache) is a browser optimization where pages navigated away from are suspended in memory rather than unloaded. When the user presses the back button, the page is restored from the suspended state instead of being reloaded. BFCache significantly improves navigation performance, but it interacts badly with long-lived connections.

In Chrome, navigating away from a page with an open EventSource connection fires the pagehide event with event.persisted = true, and the browser closes the SSE connection. When the page is restored from BFCache, a new EventSource is constructed (or the application code must explicitly reconnect). The browser does not leave a dangling connection open to a page that is suspended in BFCache.

In Safari, the behavior has historically been different. Safari may leave the EventSource connection open while the page is suspended in BFCache. The server continues writing to the connection. The suspended page does not consume the incoming bytes. The TCP receive buffer fills. Depending on whether the server respects TCP back-pressure, one of two failure modes follows:

  • Server does not respect back-pressure: The server's write buffer grows until it hits a process or OS limit. If the limit is high (which is often the case for cloud-hosted servers with generous memory), the buffer can accumulate megabytes of data. When the page is eventually restored from BFCache, the accumulated bytes arrive as a burst that the client parser must handle all at once. If the event parser is incremental (as most are), this causes a large burst of events that may overwhelm the UI update loop.
  • Server respects back-pressure: TCP back-pressure stalls the server's write. This is the correct behavior, but if the server does not have a write timeout on the connection, it will hold the suspended client's connection slot indefinitely — and every connection slot has a cost.

The correct fix on the server side is a write timeout: if a single write does not complete within a defined interval (e.g., 5 seconds), close the stream. The client will eventually reconnect via EventSource's built-in mechanism. The correct fix on the client side is to listen to the pagehide and pageshow events and explicitly close and reopen the EventSource:

let evtSource: EventSource | null = null;
 
function openStream(url: string, lastEventId?: string): EventSource {
  const fullUrl = lastEventId
    ? `${url}?lastEventId=${encodeURIComponent(lastEventId)}`
    : url;
  const source = new EventSource(fullUrl);
  source.addEventListener('token', (e: MessageEvent) => {
    handleToken(e.data, e.lastEventId);
  });
  source.addEventListener('error', () => {
    // EventSource will auto-reconnect; log the error for observability
    console.warn('SSE connection dropped, auto-reconnecting');
  });
  return source;
}
 
// Safari BFCache workaround: close SSE when page is suspended
window.addEventListener('pagehide', (e: PageTransitionEvent) => {
  if (e.persisted) {
    // Page is being cached — close the connection to prevent buffer accumulation
    evtSource?.close();
    evtSource = null;
  }
});
 
window.addEventListener('pageshow', (e: PageTransitionEvent) => {
  if (e.persisted && evtSource === null) {
    // Page restored from BFCache — reopen with last known ID
    const lastId = getLastReceivedEventId();
    evtSource = openStream('/api/stream', lastId);
  }
});

This pattern is not hypothetical — it is the behavior required for Safari compatibility with any long-lived SSE connection. The 30 KB/s threshold in the lede is real: it corresponds to a Safari configuration where the network is just fast enough for the TCP receive buffer to fill without triggering immediate back-pressure, creating a failure mode that is invisible in Chrome but reproducible on Safari within a few minutes of backgrounding.

The pattern

When SSE is the right answer

SSE wins when the data flow is fundamentally unidirectional from server to client and the interaction model does not require the client to send data back on the same channel.

LLM streaming is the canonical 2026 use case. The Anthropic API (as documented in the streaming messages reference) streams completions as SSE events with named event types — message_start, content_block_delta, message_delta, message_stop — over a connection with Content-Type: text/event-stream. The OpenAI streaming API follows the same pattern, using data: [DONE] as the termination sentinel. Mistral, Groq, and every other major model API that supports streaming uses the same transport. The reasons are uniform across the industry: SSE requires no protocol upgrade, works through any HTTP proxy without configuration, and has a simpler failure model for long-running inference requests where the server writes at the pace of inference.

If your application proxies any of these APIs — as most LLM-integrated applications do — you are already handling SSE on the backend, and using SSE end-to-end (backend to browser) avoids a protocol translation layer.

Dashboard and telemetry feeds with high update rates and no client-initiated events are a natural fit. A deployment pipeline status UI, a metrics chart that updates every second, a log tail endpoint — all of these are server-push workloads. The client never needs to send data on the same channel; user interactions (filtering, pausing, navigating) go through separate REST endpoints.

Notification systems where the server pushes events asynchronously and the client acknowledges via separate REST calls fit SSE well. The browser's native reconnect behavior means notification delivery is resilient without custom client code.

Deploy and build logs are a particularly clean fit: the log stream is append-only, server-push, and resumable by line number (which maps naturally to the id field). A client that reconnects after a network interruption can resume from the last log line it received.

SSE + REST

Unidirectional push. Writes go through separate HTTP requests. Native EventSource auto-reconnects via Last-Event-ID. Works transparently over HTTP/2 multiplexing. No upgrade handshake. Simpler proxy configuration. Best for: LLM streaming, dashboards, notifications, deploy logs.

WebSocket

Full-duplex bidirectional. Binary and text payloads. Native ping/pong frames. Lower per-message overhead for small frequent messages. Explicit bufferedAmount backpressure signal. Requires custom reconnect logic. Best for: collaborative editing, multiplayer, chat with typing indicators, financial trading.

When WebSocket is the right answer

WebSocket wins when the client needs to send data at high frequency or low latency on the same channel as server pushes, or when bidirectional framing efficiency matters at the message level.

Collaborative editing is the canonical case. An operational transform or CRDT-based editor sends user operations continuously as the user types and receives remote operations from other users. The round-trip timing of these operations affects perceived latency directly — a 200ms delay between a keystroke and its acknowledgment from the server is perceptible. More importantly, the client is sending many small messages (individual keystrokes, cursor movements, selections), and each one cannot be a separate HTTP POST without introducing per-request overhead that accumulates visibly.

Multiplayer games and interactive simulations require sub-100ms round trips for game state synchronization. WebSocket's single persistent connection with minimal per-frame overhead is essential here. SSE + REST would add an HTTP round-trip overhead to every player input, which is unacceptable for latency-sensitive mechanics.

Chat applications with typing indicators send a stream of presence events (typing started, typing stopped, user is online) that originate from the client and must reach other clients within 1–2 seconds. Managing this as SSE + REST is possible but requires either a polling endpoint for presence or a parallel SSE channel — at which point the complexity exceeds a single WebSocket connection.

Financial trading interfaces that display real-time order book updates and accept order submissions have an inherent bidirectional requirement. The order book is server-push; the order submission is client-push. Running these through separate SSE and REST channels introduces asynchrony that can create race conditions (e.g., an order submitted just as an order book update is being processed). WebSocket keeps both on the same connection with the same sequencing guarantees.

The hybrid case

Many production systems use both protocols for different parts of the same feature. A collaborative document editor might use WebSocket for the operational transform layer (where latency and bidirectionality are critical) and SSE for the activity feed on the right side panel (which is purely server-push and doesn't need to be on the same connection as edit operations). A trading platform might use WebSocket for the order management interface and SSE for the market data dashboard that executives view on read-only screens.

The engineering overhead of maintaining two transport layers is real, but it is often less than the overhead of forcing SSE into a bidirectional workload or forcing WebSocket into a read-only push workload that would benefit from SSE's native reconnect and proxy transparency.

The fix / approach

Server-side SSE endpoint with write timeout and backpressure hygiene

The minimum viable SSE endpoint must handle three things that tutorials typically omit: connection detection, write timeouts, and the Last-Event-ID resume header.

  1. Set correct response headers and configure the write timeout

    The response must declare Content-Type: text/event-stream, disable caching, and — critically — set X-Accel-Buffering: no to prevent nginx or any nginx-compatible proxy from buffering the response. Without this header, nginx's default proxy_buffering on setting will accumulate the entire stream in a memory buffer before forwarding it to the client, defeating the entire purpose of streaming.

    import { Request, Response } from 'express';
     
    export function sseHandler(req: Request, res: Response): void {
      // Required headers for SSE
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache, no-transform');
      res.setHeader('Connection', 'keep-alive');
      // Disable nginx proxy buffering — without this, nginx buffers the stream
      res.setHeader('X-Accel-Buffering', 'no');
      // CORS if the client is on a different origin
      res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
     
      // Immediately flush the headers so the browser registers the SSE connection
      res.flushHeaders();
     
      // Read Last-Event-ID for stream resume
      const lastEventId = req.headers['last-event-id']
        ?? req.query.lastEventId as string
        ?? null;
     
      const WRITE_TIMEOUT_MS = 5_000; // Close connection if write stalls for 5s
      let writeTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
     
      function safeWrite(chunk: string): boolean {
        if (res.destroyed || res.writableEnded) return false;
     
        writeTimeoutHandle = setTimeout(() => {
          // Write stalled — close the connection; client will auto-reconnect
          console.warn('SSE write timeout; closing connection for reconnect');
          res.end();
        }, WRITE_TIMEOUT_MS);
     
        const flushed = res.write(chunk, 'utf8');
        clearTimeout(writeTimeoutHandle);
        writeTimeoutHandle = null;
     
        if (!flushed) {
          // Node.js write buffer is full — drain before continuing
          return false; // Caller should wait for 'drain' event
        }
        return true;
      }
     
      // Heartbeat: send a comment line every 15s to detect dead connections
      // and prevent proxy idle-connection timeouts
      const heartbeatInterval = setInterval(() => {
        safeWrite(': ping\n\n');
      }, 15_000);
     
      // Cleanup on client disconnect
      req.on('close', () => {
        clearInterval(heartbeatInterval);
        if (writeTimeoutHandle) clearTimeout(writeTimeoutHandle);
      });
     
      // Pass safeWrite and lastEventId to the business logic layer
      startEventStream(safeWrite, lastEventId, req.on.bind(req));
    }
  2. Implement the event log with ID-based resume

    Every event written to the SSE stream must carry an id: field. The server maintains a durable log of events (in Postgres, Redis, or any persistent store) keyed by a monotonically increasing ID. When the client reconnects with Last-Event-ID, the server queries events since that ID and writes them before switching to live events.

    interface SseEvent {
      id: string;        // Monotonic string ID (e.g., Postgres BIGSERIAL as string)
      event?: string;    // Optional named event type
      data: string;      // Payload — must be a single line (newlines replaced)
      retry?: number;    // Optional reconnect timer override in milliseconds
    }
     
    function formatSseEvent(event: SseEvent): string {
      const lines: string[] = [];
      if (event.retry !== undefined) lines.push(`retry: ${event.retry}`);
      if (event.id) lines.push(`id: ${event.id}`);
      if (event.event) lines.push(`event: ${event.event}`);
      // Spec requires each data: line to be a separate line for multi-line payloads
      const dataLines = event.data.split('\n');
      for (const line of dataLines) lines.push(`data: ${line}`);
      lines.push('', ''); // Two newlines terminate the event
      return lines.join('\n');
    }
     
    async function startEventStream(
      write: (chunk: string) => boolean,
      lastEventId: string | null,
      onRequest: (event: string, handler: () => void) => void,
    ): Promise<void> {
      let closed = false;
      onRequest('close', () => { closed = true; });
     
      // Send retry hint so client reconnects within 3 seconds
      write('retry: 3000\n\n');
     
      // Replay missed events if client is reconnecting
      if (lastEventId) {
        const missed = await queryEventsSince(lastEventId);
        for (const event of missed) {
          if (closed) return;
          write(formatSseEvent(event));
        }
      }
     
      // Subscribe to live events
      const unsubscribe = subscribeToLiveEvents((event: SseEvent) => {
        if (closed) { unsubscribe(); return; }
        write(formatSseEvent(event));
      });
     
      onRequest('close', unsubscribe);
    }
  3. Handle the drain event for slow clients

    When Node.js's internal write buffer is full, res.write() returns false. Continuing to call write() queues data in memory and can exhaust the process heap for slow clients. The correct response is to pause sending until the drain event fires:

    async function writeWithBackpressure(
      res: Response,
      chunk: string,
    ): Promise<void> {
      const flushed = res.write(chunk, 'utf8');
      if (!flushed) {
        // Wait for the OS-level write buffer to drain before continuing
        await new Promise<void>((resolve) => {
          res.once('drain', resolve);
        });
      }
    }

    For live event streams where you cannot afford to pause (e.g., LLM token delivery), implement a bounded in-memory queue per connection:

    class SseSender {
      private queue: string[] = [];
      private draining = false;
      private readonly MAX_QUEUE = 100; // Drop events beyond this threshold
     
      constructor(private readonly res: Response) {
        res.on('drain', () => this.flush());
      }
     
      enqueue(chunk: string): void {
        if (this.queue.length >= this.MAX_QUEUE) {
          // Drop excess events under sustained backpressure
          // For LLM streaming, this is appropriate for heartbeats but not token events
          console.warn('SSE queue full; dropping event');
          return;
        }
        this.queue.push(chunk);
        if (!this.draining) this.flush();
      }
     
      private flush(): void {
        this.draining = true;
        while (this.queue.length > 0) {
          const chunk = this.queue.shift()!;
          const flushed = this.res.write(chunk, 'utf8');
          if (!flushed) return; // Wait for 'drain'
        }
        this.draining = false;
      }
    }
  4. Implement WebSocket reconnect for bidirectional use cases

    If your workload requires WebSocket, you must implement reconnect from scratch. The standard pattern is exponential backoff with jitter and a sliding window for detecting stable connections:

    interface WsReconnectOptions {
      url: string;
      onMessage: (event: MessageEvent) => void;
      onOpen?: () => void;
      onClose?: () => void;
      initialDelayMs?: number;
      maxDelayMs?: number;
      jitterFactor?: number;
    }
     
    class ReconnectingWebSocket {
      private ws: WebSocket | null = null;
      private attemptCount = 0;
      private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
      private closed = false;
     
      private readonly url: string;
      private readonly onMessage: (event: MessageEvent) => void;
      private readonly onOpen?: () => void;
      private readonly onClose?: () => void;
      private readonly initialDelay: number;
      private readonly maxDelay: number;
      private readonly jitter: number;
     
      constructor(opts: WsReconnectOptions) {
        this.url = opts.url;
        this.onMessage = opts.onMessage;
        this.onOpen = opts.onOpen;
        this.onClose = opts.onClose;
        this.initialDelay = opts.initialDelayMs ?? 1_000;
        this.maxDelay = opts.maxDelayMs ?? 30_000;
        this.jitter = opts.jitterFactor ?? 0.2;
        this.connect();
      }
     
      private connect(): void {
        if (this.closed) return;
        this.ws = new WebSocket(this.url);
     
        this.ws.onopen = () => {
          this.attemptCount = 0; // Reset backoff on successful connection
          this.onOpen?.();
        };
     
        this.ws.onmessage = this.onMessage;
     
        this.ws.onclose = (event) => {
          this.onClose?.();
          if (!this.closed && !event.wasClean) {
            this.scheduleReconnect();
          }
        };
     
        this.ws.onerror = () => {
          // onerror always precedes onclose; reconnect logic is in onclose
        };
      }
     
      private scheduleReconnect(): void {
        const baseDelay = Math.min(
          this.initialDelay * Math.pow(2, this.attemptCount),
          this.maxDelay,
        );
        // Full jitter: uniform random in [0, baseDelay * (1 + jitter)]
        const delay = baseDelay * (1 + this.jitter * Math.random());
        this.attemptCount++;
        this.reconnectTimer = setTimeout(() => this.connect(), delay);
      }
     
      send(data: string): void {
        if (this.ws?.readyState === WebSocket.OPEN) {
          // Check backpressure before sending
          if (this.ws.bufferedAmount > 64 * 1024) {
            throw new Error('WebSocket buffer full — apply backpressure');
          }
          this.ws.send(data);
        }
      }
     
      close(): void {
        this.closed = true;
        if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
        this.ws?.close(1000, 'Client closed');
      }
    }

    The bufferedAmount check before every send() is the application-level backpressure mechanism that SSE does not have. A client that detects its buffer filling can pause, drop non-critical events, or signal the server to slow its write rate — options that are simply not available on the SSE side.

  5. Configure nginx correctly for SSE

    The single most common SSE deployment failure is nginx proxy buffering. nginx's default proxy_buffering on directive causes nginx to accumulate the entire proxied response before forwarding it to the downstream client. For SSE, this means the client receives nothing until the stream ends — which, for a long-lived LLM completion, could be tens of seconds.

    The two correct approaches are:

    # Option 1: Disable proxy buffering globally for the SSE location
    location /api/stream {
        proxy_pass http://backend;
        proxy_buffering off;
        proxy_cache off;
        # Without these, nginx may close the connection after proxy_read_timeout
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        keepalive_timeout 3600s;
    }
     
    # Option 2: Let the backend control buffering via X-Accel-Buffering header
    # (nginx respects this header by default when proxy_buffering is on)
    location /api/ {
        proxy_pass http://backend;
        # Backend sets "X-Accel-Buffering: no" on SSE responses
        # nginx disables buffering for that specific response
    }

    Option 2 is preferable for mixed endpoints where only some responses are SSE — it lets the application declare its own buffering requirements rather than requiring separate nginx location blocks per endpoint type.

    Set proxy_read_timeout and proxy_send_timeout to a value longer than your longest expected stream. The default is 60 seconds. An LLM completion generating a 10,000-word document can easily exceed that limit with a slow model. 3600 seconds (1 hour) is a reasonable ceiling.

  6. Cloudflare and edge network considerations

    Cloudflare's CDN buffers HTTP responses by default. For SSE endpoints, you must ensure the response passes through without buffering. Cloudflare does respect the Content-Type: text/event-stream header — it will not cache SSE responses — but it may still buffer the response body before forwarding it downstream in some configurations.

    The reliable mitigation is to set X-Accel-Buffering: no on SSE responses (which Cloudflare passes through) and to use a Cloudflare Worker if you need guaranteed streaming behavior. Cloudflare Workers support the WHATWG Streams API natively, which allows you to stream responses without buffering by constructing a ReadableStream and writing to it incrementally. For SSE proxying through a Worker, the stream is written token by token and flushed immediately.

    For WebSocket, Cloudflare proxies WebSocket connections transparently over WebSocket, not HTTP/2 CONNECT (RFC 8441). This means WebSocket connections through Cloudflare are not multiplexed — each is a separate TCP connection. This is generally not a problem for most applications but is worth knowing if you are doing connection capacity planning at scale.

Anti-patterns

Implementing manual reconnect for SSE when EventSource does it automatically

// Anti-pattern: manual reconnect loop that fights EventSource's built-in behavior
let source = new EventSource('/api/stream');
source.onerror = () => {
  source.close();
  setTimeout(() => {
    source = new EventSource('/api/stream'); // Loses last event ID
  }, 3000);
};

Why it fails: this approach loses the lastEventId that EventSource tracks internally and sends via the Last-Event-ID header. The reconnected stream starts from the beginning, causing duplicate events or missed events depending on server-side implementation. The correct behavior is to do nothing — EventSource reconnects automatically and sends Last-Event-ID without any application code.

Do instead: use EventSource as-is and only intervene to close and reopen the connection on events that require it (page visibility changes, explicit user action, authentication expiry).

Using WebSocket for read-only server-push to avoid SSE "limitations"

// Anti-pattern: full WebSocket for a metrics dashboard that only reads
const ws = new WebSocket('wss://api.example.com/metrics');
ws.onopen = () => ws.send(JSON.stringify({ subscribe: 'cpu_usage' }));
ws.onmessage = (e) => updateChart(JSON.parse(e.data));
// No reconnect logic — connection silently dies after network hiccup
// Custom heartbeat via setInterval — duplicates what SSE comment lines do natively

Why it fails: the team chose WebSocket to avoid SSE's supposed connection limit, but their deployment was already on HTTP/2, so the limit was irrelevant. They then had to implement reconnect, custom heartbeat, and subscription message format that SSE handles natively or trivially. Three months later, the lack of reconnect logic caused silent data loss on mobile clients with flaky connections.

Do instead: use SSE + REST for server-push-only workloads. Save WebSocket for cases where the client genuinely needs to send frequent data on the same channel.

Forgetting to set X-Accel-Buffering: no behind nginx

// Anti-pattern: SSE endpoint without the nginx buffering disable header
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
// Missing: res.setHeader('X-Accel-Buffering', 'no');

Why it fails: works perfectly in local development (no nginx). Fails silently in staging and production where nginx buffers the entire stream. The client receives the full response body in one burst when the stream eventually closes. For LLM streaming this means no tokens appear until the completion finishes — exactly the opposite of the intended UX.

Do instead: always set X-Accel-Buffering: no on text/event-stream responses, or configure nginx explicitly with proxy_buffering off on the SSE location block.

Treating SSE backpressure as "not my problem"

The assumption: "SSE runs over TCP, TCP handles backpressure, I don't need to think about it."

Why it fails: TCP backpressure is a coarse, global signal. It does not distinguish between a slow client and a dead client. A suspended Safari tab with a full TCP receive buffer holds a server connection slot indefinitely unless the server has an explicit write timeout. At scale, a small percentage of BFCached or crashed browser tabs can accumulate server-side, consuming file descriptors and memory.

Do instead: set a write timeout on every SSE connection. If a write does not complete within the timeout, close the connection. The client will reconnect with Last-Event-ID and resume from where it left off.

// Pattern: write with timeout
function writeWithTimeout(
  res: Response,
  chunk: string,
  timeoutMs: number,
): Promise<boolean> {
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      res.end(); // Force close the connection
      resolve(false);
    }, timeoutMs);
 
    const flushed = res.write(chunk, 'utf8', () => {
      clearTimeout(timer);
      resolve(true);
    });
 
    if (!flushed) {
      // Drain will trigger the callback above
    }
  });
}

Implementing WebSocket without exponential backoff

// Anti-pattern: constant-interval reconnect
ws.onclose = () => {
  setTimeout(() => new WebSocket(url), 1000); // 1 second forever
};

Why it fails: when a backend restarts and 500 clients all reconnect at 1-second intervals, the server faces a synchronised reconnect storm. A restarting server receives 500 simultaneous connection attempts rather than a gradual ramp. This can prevent the server from completing its startup sequence.

Do instead: exponential backoff with full jitter, as shown in the implementation section. Full jitter (random delay between 0 and the computed backoff ceiling) desynchronises concurrent reconnect attempts better than multiplicative jitter alone, as discussed in the AWS architecture blog's analysis of thundering herd mitigation.

Sending id: fields on every SSE event but not implementing server-side resume

// Anti-pattern: IDs without resume
source.addEventListener('token', (e: MessageEvent) => {
  // e.lastEventId is available — but if we reconnect, we restart from the beginning
  appendToken(e.data);
});
 
// Server:
res.write(`id: ${seqNum}\ndata: ${token}\n\n`);
// No Last-Event-ID handling on the server — seq numbers are theater

Why it fails: the id field creates the expectation of resumability without delivering it. When the client reconnects after a network interruption, the browser sends Last-Event-ID, the server ignores it and starts the stream from the beginning, and the client receives duplicate tokens that corrupt the rendered output.

Do instead: if you emit id: fields, implement server-side resume. Query the event log for all events with id > last_event_id and write them before switching to live events. If you do not have a server-side event log, omit the id: field entirely — the browser will reconnect without sending Last-Event-ID and the server can start fresh without creating a false resumability expectation.

Case studies and numbers

LLM streaming proxy: a real-throughput analysis

A team building a developer tool that proxies Anthropic API completions through their own backend started with a naive implementation: receive the SSE stream from the Anthropic API, buffer the full response, then return it to the client as a non-streaming JSON response. Round-trip latency for a 500-token completion was 8–12 seconds, during which the user saw a spinner.

They switched to pass-through SSE streaming: the backend opened the Anthropic SSE connection and forwarded each content_block_delta event to the client as it arrived, with the id: field set to the chunk sequence number. Perceived latency dropped to under 200ms — the user saw the first token within 200ms of the request, matching the time for the first token to be generated by the model.

The implementation required three changes from the naive version:

  1. Set X-Accel-Buffering: no on the outgoing response (the team had nginx in front).
  2. Set proxy_read_timeout 120s on the nginx proxy (default 60s was causing premature stream termination on long completions).
  3. Add a write timeout on the forwarding loop to handle suspended browser tabs.

The Safari BFCache issue described in the lede was discovered during load testing at this point. The fix was the pagehide/pageshow event pair on the client side and a 5-second write timeout on the server side. After the fix, the SSE connection would close within 5 seconds of the tab being suspended, freeing the server-side connection slot, and reconnect within 3 seconds of the tab being restored from BFCache.

WebSocket collab editor: backpressure under document surge

A team building a multiplayer code review tool encountered a failure mode when a large pull request (500+ changed files) was opened simultaneously by a team of 30 reviewers. The server computed inline comments for each file as they were loaded, fanning out comment events to all connected clients. Each client received approximately 200 events in the first 5 seconds.

Clients on fast connections handled this without issue. Clients on slower connections — particularly mobile reviewers — fell behind. Without application-level backpressure, the server continued sending at full rate regardless of how fast each client was consuming. Mobile clients with full TCP receive buffers triggered TCP back-pressure, which slowed the entire TCP connection including real-time typing events from other users. The review tool felt unresponsive for mobile users even when they were not interacting with the comment-heavy files.

The fix was a server-side per-connection send queue with a bounded depth, checked against socket.bufferedAmount before each send:

const MAX_BUFFER_BYTES = 32 * 1024; // 32 KB per connection
 
function sendToClient(ws: WebSocket, event: ReviewEvent): void {
  if (ws.readyState !== WebSocket.OPEN) return;
 
  // Use the Node.js net.Socket's bufferSize for server-side check
  const sock = (ws as any)._socket as import('net').Socket;
  if ((sock?.bufferSize ?? 0) > MAX_BUFFER_BYTES) {
    if (event.priority === 'realtime') {
      // Drop typing indicators and cursor positions under backpressure
      return;
    }
    // Queue durable events (comment syncs) for delivery after buffer drains
    enqueueForConnection(ws, event);
    return;
  }
  ws.send(JSON.stringify(event));
}

After this change, mobile clients on slow connections no longer caused latency spikes for other users. Durable comment sync events were delivered within 2–5 seconds after the initial surge (as each client's buffer drained), and real-time typing indicators were simply dropped for clients that were too slow to receive them — an acceptable loss given that a typing indicator that arrives 10 seconds late has no value.

We spent a week blaming the mobile network before we instrumented bufferedAmount. Once we could see the buffer size per connection in our metrics, the problem was obvious in five minutes. Mobile clients were sitting at 800 KB buffered on a 100-event surge. The TCP stall was real and measurable.

— Backend engineer, SaaS code review tool

Notification service: SSE at 50,000 concurrent connections

A team running a developer notification service — build status, deployment alerts, CI failures — evaluated SSE versus WebSocket before building. Their constraints:

  • 50,000 concurrent connections expected at peak
  • Events arrive at 1–5 per second per connection on average
  • Clients are authenticated via session cookie
  • No client-to-server messages required (users click links to dismiss notifications, which go through REST)
  • Multi-region deployment (3 regions)

They chose SSE for three reasons:

  1. The workload is pure server-push. No client-to-server messaging on the notification channel itself.
  2. HTTP/2 multiplexing meant SSE connections did not consume additional TCP connections beyond what background browser HTTP traffic was already using.
  3. EventSource's automatic reconnect with Last-Event-ID meant missed notifications during network interruptions were automatically replayed, which was essential for CI failure alerts that must not be silently lost.

At 50,000 concurrent SSE connections, each connection is an open file descriptor on the server. The team used Node.js with the cluster module, distributing connections across CPU cores, and set ulimit -n 200000 (200,000 open file descriptors per process). Each connection consumed approximately 8 KB of memory at rest (Node.js EventEmitter overhead plus the SSE response stream object). 50,000 connections therefore consumed roughly 400 MB of memory — within budget.

The event fan-out used Redis Pub/Sub: each user's notification events were published to a Redis channel keyed by notifications:user:{userId}. Application server instances subscribed to channels for the users connected to their local process. This gave per-user isolation without cross-user broadcast noise.

The first version used WebSocket because that's what the team knew. Reconnect logic alone was 400 lines. We rewrote to SSE in two days. The reconnect logic is zero lines — EventSource does it. The server implementation is simpler too. We kept the WebSocket version's Redis fan-out approach because that part was correct.

— Platform infrastructure engineer, developer tools company

Limits and caveats

SSE does not work for binary payloads without encoding

The text/event-stream format is strictly UTF-8 text. Binary data (images, audio, protobuf) must be base64-encoded before it can be sent over SSE, adding roughly 33% overhead. For applications that need to stream binary data — audio from a speech synthesis API, for example — WebSocket binary frames are the correct transport. SSE with base64 is functional but inefficient.

EventSource does not support custom request headers in the browser

The browser's native EventSource constructor does not accept custom headers. Authentication via cookies works because cookies are sent automatically. Authentication via a bearer token in the Authorization header does not work without a custom implementation. The workarounds are:

  • Pass the token as a query parameter in the URL (acceptable for short-lived tokens; exposes the token in server logs and proxy access logs).
  • Use a fetch-based SSE implementation that uses fetch() with ReadableStream instead of EventSource, which supports arbitrary headers.
  • Set the token in a cookie (if same-origin or same-site) and use cookie-based authentication.

The fetch-based approach sacrifices automatic reconnect (which must then be reimplemented) in exchange for header flexibility.

The 6-connection limit applies to HTTP/1.1 origins only

The HTTP/1.1 connection limit of 6 connections per browser+origin applies to SSE only under HTTP/1.1. If your deployment serves HTTP/2 — which most modern TLS-terminating load balancers and CDNs do by default — this limit is effectively irrelevant. Verify your deployment's HTTP version before treating connection limits as a constraint. If you are seeing unexpected SSE connection failures in Chrome or Firefox, check whether the affected requests are over HTTP/1.1 using the browser's Network panel.

WebSocket and HTTP/2 multiplexing require explicit server + proxy support

RFC 8441 (WebSocket over HTTP/2) requires both the server and any proxies in the path to support the extended CONNECT method. Many proxies and load balancers still route WebSocket connections via HTTP/1.1 CONNECT tunneling, bypassing HTTP/2 multiplexing. Do not assume WebSocket connections are multiplexed in your deployment without verifying the negotiated protocol in your proxy access logs.

SSE streams over Cloudflare may have higher latency than direct connections

Cloudflare's anycast network routes SSE connections through its global edge network. For SSE streams delivering time-sensitive data (live stock prices, real-time telemetry), the additional routing hop adds latency that may be unacceptable. Measure the added latency in your target deployment regions before assuming Cloudflare is neutral for real-time use cases.

The write timeout pattern changes stream semantics for slow clients

A server-side write timeout causes the server to close the SSE stream when a write stalls, forcing the client to reconnect. For LLM completions, this means a client with a temporarily slow network may receive the first 500 tokens, lose the stream, reconnect via Last-Event-ID, replay the first 500 tokens, and then continue from there. The client must correctly detect and deduplicate the replayed tokens. If the client renders each received event without deduplication logic, the user will see the first 500 tokens appear twice. Design the event IDs and client reconciliation logic to handle replay before enabling write timeouts.

Sources and further reading