Topic #36Core14 min read

Server-Sent Events (SSE)

One-directional server-to-client streaming over a single long-lived HTTP connection via the native EventSource API: dead simple, auto-reconnecting with Last-Event-ID resume, proxy-friendly, and ideal for feeds, notifications, live logs, tickers and LLM token streams — with clear limits that tell you when to reach for WebSockets instead.

#sse#eventsource#streaming#real-time#http#text-event-stream#auto-reconnect#llm-streaming#http2

What SSE is. Server-Sent Events provide a one-directional stream from server to client over a single, long-lived HTTP connection, using the browser's built-in EventSource API. The server holds the response open and keeps writing text events as they happen; the browser dispatches each one to your handler. There is no protocol upgrade and no client-to-server channel — you only listen. That simplicity is the whole appeal.

The wire format is trivial. SSE is just a text/event-stream HTTP response made of newline-delimited fields: data: (the payload, can span multiple data: lines), event: (a named event type), id: (an event id the browser remembers), and retry: (how many ms to wait before reconnecting). A blank line dispatches the event. That's the entire protocol — you could hand-write it, which is why any backend that can stream a response can do SSE.

The client API — three lines. Create new EventSource(url); handle the default stream in onmessage (parsing event.data, which is always a string — usually JSON.parse it); and handle transport failures in onerror. For named events sent with event: foo, you subscribe with source.addEventListener('foo', handler) instead of onmessage. source.readyState is CONNECTING/OPEN/CLOSED, and source.close() shuts it down.

Built-in auto-reconnect — the killer feature. If the connection drops, EventSource reconnects automatically with no code from you. The server can tune the delay with a retry: field. Even better, the browser tracks the last id: it saw and sends it back as the Last-Event-ID request header on reconnect, so your server can resume from where it left off and replay missed events. WebSockets give you none of this for free — you build reconnection and resync yourself.

Why SSE passes through infrastructure so easily. Because SSE is 'just' a long-running HTTP GET, it sails through proxies, corporate firewalls, and load balancers that sometimes block or mishandle the WebSocket Upgrade. No special routing, no sticky-session gymnastics for the transport itself, works with standard HTTP auth, cookies, and CORS. This operational simplicity is a real reason teams pick SSE for server-to-client streaming.

The HTTP/1.1 connection-limit trap. SSE's classic gotcha: over HTTP/1.1, browsers cap concurrent connections per origin at ~6. Each open EventSource consumes one, so a few tabs (or several streams) can starve your other requests. HTTP/2 (and HTTP/3) fix this by multiplexing many streams over one connection, raising the effective limit dramatically. Serve SSE over HTTP/2 in production, and avoid opening many separate EventSource connections per page.

Text only, and one-way. SSE carries UTF-8 text only — binary must be base64-encoded (bloating it ~33%), so it's a poor fit for binary-heavy data. And it is strictly server-to-client: the only thing the client sends is the initial request. If you need to push data up frequently, SSE alone can't; you either send those over separate fetch/POST requests or switch to WebSockets. Sending occasional commands via normal HTTP alongside an SSE stream is a perfectly common hybrid.

SSE and LLM token streaming. A modern, very common use: streaming AI/LLM responses token-by-token. The server emits each chunk as an SSE data: event and the UI renders text as it arrives, giving that 'typing' effect. (Note: many LLM APIs use the SSE wire format over a fetch POST body rather than the native EventSource, precisely because EventSource can only GET and can't set an Authorization header — see the auth caveat below.) SSE's push-and-append model fits streaming completions perfectly.

Auth caveat with native EventSource. The native EventSource can only issue a GET and cannot set custom headers (no Authorization). Options: rely on cookies (sent automatically, subject to CORS withCredentials: true), put a token in the query string (careful with logging), or — when you need headers — skip EventSource and consume the SSE stream yourself via fetch with ReadableStream. This last approach is why LLM SDKs parse SSE manually.

Using it in React — close on cleanup. Create the EventSource inside useEffect, wire onmessage/addEventListener and onerror, and source.close() in the cleanup so the connection is torn down on unmount or when the URL changes. Otherwise you leak an open HTTP connection (and one of those precious per-origin slots) and may accumulate duplicate handlers. Reconnection is automatic, but closing is still your job.

Choosing among the three real-time technologies. SSE sits between the heavyweight WebSocket and the app-closed Push:

TechnologyDirectionConnectionAuto-reconnectBest For
WebSocketBidirectionalPersistent TCP (ws/wss)No (DIY)Chat, collaboration, live dashboards, gaming
SSE (EventSource)Server -> Client onlyPersistent HTTPYes (built-in, Last-Event-ID)Feeds, notifications, live logs, tickers, LLM token streams
Push NotificationsServer -> DeviceVia service workerN/AAlerts when the app is closed/backgrounded

The mental model (memorise this). SSE is a one-way server-to-client stream over a single long-lived HTTP text/event-stream connection, consumed with the native EventSource: it is the simplest real-time option, auto-reconnects and resumes via Last-Event-ID for free, and passes through proxies easily — its costs are text-only payloads, no client-to-server channel, the HTTP/1.1 per-origin connection cap (fixed by HTTP/2), and native EventSource's GET-only/no-custom-headers limit; reach for it for feeds, notifications, logs, tickers and LLM streams, and switch to WebSockets only when you also need frequent client-to-server or binary traffic.

Backend Analogy

SSE is a **long-lived, chunked HTTP response** the server keeps writing to — the direct analogue of a Spring WebFlux `Flux<ServerSentEvent>` endpoint or a Vert.x route whose handler holds the `HttpServerResponse` open and calls `write()` for each event with `Content-Type: text/event-stream`. Because it rides ordinary HTTP, it traverses proxies, load balancers, and firewalls the same way any GET does — no upgrade negotiation, unlike the WebSocket handshake — which is why it 'just works' behind infrastructure that trips up sockets. The `Last-Event-ID` resume is at-least-once/replay semantics driven by the client's cursor, the same idea as a Kafka consumer resuming from a stored offset after a restart. And the HTTP/1.1 six-connection cap is a client-side connection-pool limit — HTTP/2 multiplexing lifts it exactly like moving from one-request-per-connection to a pooled, multiplexed transport on the server side.

Key Insights
  • SSE is a one-directional server-to-client stream over a single long-lived HTTP connection, consumed with the native EventSource API.
  • The wire format is trivial text/event-stream with data:, event:, id:, and retry: fields; a blank line dispatches an event.
  • Client usage is minimal: new EventSource(url), onmessage (event.data is always a string, usually JSON.parse it), and onerror; named events use addEventListener.
  • EventSource auto-reconnects for free and, via the Last-Event-ID header, lets the server resume and replay missed events.
  • Because it is plain HTTP, SSE passes through proxies, firewalls, and load balancers more easily than a WebSocket upgrade.
  • Over HTTP/1.1 browsers cap ~6 connections per origin, so many EventSource connections can starve other requests; HTTP/2 multiplexing fixes this.
  • SSE carries UTF-8 text only (binary needs base64) and has no client-to-server channel beyond the initial request.
  • It is ideal for feeds, notifications, live logs, tickers, and streaming LLM tokens token-by-token.
  • Native EventSource can only GET and cannot set custom headers, so auth uses cookies or a query token, or you parse SSE manually via fetch + ReadableStream.
  • In React, create the EventSource in useEffect and call source.close() in the cleanup; reconnection is automatic but closing is your job.

Worked Code

EventSource — default messages, named events, errors
TypeScript
// One-directional server -> client stream. Auto-reconnects on drop.
const source = new EventSource('/api/stream');

// Default (unnamed) events land here. event.data is ALWAYS a string.
source.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  console.log('message', payload);
};

// Named events sent by the server as "event: price" use addEventListener.
source.addEventListener('price', (event) => {
  const tick = JSON.parse((event as MessageEvent).data);
  console.log('price tick', tick);
});

source.onopen = () => console.log('stream open', source.readyState); // 1 = OPEN

source.onerror = () => {
  // Fired on transport errors. EventSource will auto-reconnect unless CLOSED.
  if (source.readyState === EventSource.CLOSED) console.log('stream closed for good');
  // Call source.close() here only if YOU want to stop retrying.
};
The SSE wire format (what the server actually writes)
Shell
# A text/event-stream response is just newline-delimited fields.
# A BLANK LINE dispatches the event to the browser.

# --- default message event (goes to onmessage) ---
data: {"user":"ada","text":"hello"}

# --- named event with an id (browser remembers the id) ---
event: price
id: 42
data: {"symbol":"ACME","price":101.25}

# --- tell the client to wait 5s before reconnecting ---
retry: 5000

# --- a comment/heartbeat line (starts with ':') keeps the connection alive ---
: keep-alive ping

# On reconnect the browser sends:  Last-Event-ID: 42
# so the server can resume from event 42 and replay anything missed.
Minimal SSE server endpoint (Node)
JavaScript
// Any backend that can stream a response can do SSE — no library needed.
const http = require('http');

http.createServer((req, res) => {
  if (req.url !== '/api/stream') { res.writeHead(404).end(); return; }

  // The three headers that make it a stream.
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  // Resume support: the browser sends the last id it saw on reconnect.
  const lastId = Number(req.headers['last-event-id'] || 0);
  let id = lastId;

  const timer = setInterval(() => {
    id += 1;
    // event/id/data + a BLANK line to dispatch.
    res.write(`event: tick\n`);
    res.write(`id: ${id}\n`);
    res.write(`data: ${JSON.stringify({ id, time: Date.now() })}\n\n`);
  }, 1000);

  // Clean up when the client disconnects (tab closed / source.close()).
  req.on('close', () => clearInterval(timer));
}).listen(3000);
SSE in a React hook — subscribe and close on cleanup
TSX
import { useEffect, useState } from 'react';

function useLiveFeed<T>(url: string) {
  const [items, setItems] = useState<T[]>([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const source = new EventSource(url); // withCredentials: true to send cookies (auth)

    source.onopen = () => setConnected(true);
    source.onmessage = (e) => {
      const item = JSON.parse(e.data) as T;
      setItems((prev) => [item, ...prev].slice(0, 100));
    };
    source.onerror = () => setConnected(false); // it will auto-reconnect on its own

    // CRITICAL: close the stream on unmount / url change, or you leak an open
    // HTTP connection (and one of the ~6 per-origin slots under HTTP/1.1).
    return () => source.close();
  }, [url]);

  return { items, connected };
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Parse the raw SSE wire format the way EventSource does

Interview-Ready Q&A

When data only needs to flow server-to-client — live feeds, notifications, logs, tickers, LLM token streams — SSE is simpler: it uses the native EventSource API over plain HTTP with no protocol upgrade, it works through proxies and firewalls that may block WebSockets, it needs no sticky-session or backplane gymnastics for the transport, and it reconnects automatically with Last-Event-ID resume. WebSockets are warranted only when you also need the client to push messages frequently or you need binary payloads.

Things to Remember
  • 1SSE = one-way server -> client stream over a single long-lived HTTP text/event-stream connection via EventSource.
  • 2Wire format: data:, event:, id:, retry: fields; a blank line dispatches the event.
  • 3onmessage handles default events (data is a string — JSON.parse it); named events use addEventListener.
  • 4EventSource auto-reconnects for free and resumes via the Last-Event-ID header — WebSockets don't.
  • 5Being plain HTTP, it passes through proxies, firewalls, and load balancers easily.
  • 6HTTP/1.1 caps ~6 connections per origin; many streams starve other requests — use HTTP/2.
  • 7Text only (binary must be base64) and no client-to-server channel beyond the initial request.
  • 8Great for feeds, notifications, logs, tickers, and streaming LLM tokens.
  • 9Native EventSource is GET-only with no custom headers — auth via cookies/query token, or parse SSE via fetch + ReadableStream.
  • 10In React: create EventSource in useEffect and call source.close() in the cleanup.

References & Further Reading