πŸ“¨ SSE vs WebSockets

Two ways to stream from server to client. One is a socket. The other is just an HTTP response that never ends β€” and it's the right default more often than people think.

The Core Difference

Server-Sent Events is a one-way stream: the server pushes, the client listens. WebSockets are bidirectional. That single axis decides almost everything else.

SSE isn't a new protocol. It's a normal HTTP GET whose response has Content-Type: text/event-stream and simply never closes. The server writes text events over time; the browser's EventSource parses them. No upgrade, no framing protocol.

The SSE Wire Format

It's almost comically simple β€” line-oriented UTF-8 text, events separated by a blank line:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

id: 42
event: price
data: {"symbol":"ACME","px":19.20}

id: 43
data: line one
data: line two

: this is a comment / heartbeat to keep the connection warm

retry: 5000

Auto-Reconnect & Event IDs β€” SSE's Superpower

The browser reconnects automatically when the stream drops, and it sends the last ID it saw back in the Last-Event-ID header. The server can resume exactly where it left off. You get at-least-once resumable delivery for free.

sequenceDiagram participant B as Browser (EventSource) participant S as Server B->>S: GET /stream (Accept: text/event-stream) S-->>B: id:42 data:{...} S-->>B: id:43 data:{...} Note over B,S: connection drops B->>S: GET /stream
Last-Event-ID: 43 S-->>B: id:44 data:{...} (resumes)
WebSockets give you none of this. Reconnection, dedup, and "what did I miss?" replay are all your code. That's the hidden tax of reaching for a socket when a stream would do.

The Two Clients, Side by Side

// SSE β€” the browser handles reconnection + Last-Event-ID for you
const es = new EventSource("/stream");
es.addEventListener("price", (e) => render(JSON.parse(e.data)));
es.onerror = () => {/* browser auto-reconnects; no manual retry needed */};

// WebSocket β€” you own reconnection, heartbeats, and backoff
let ws;
function connect() {
  ws = new WebSocket("wss://example.com/feed");
  ws.onmessage = (e) => render(JSON.parse(e.data));
  ws.onclose = () => setTimeout(connect, backoff()); // your job
}
connect();

Decision Table

DimensionSSEWebSockets
DirectionServer β†’ client onlyBidirectional
ProtocolPlain HTTP (no upgrade)Upgrade to ws frames
PayloadUTF-8 text onlyText or binary
Auto-reconnectBuilt inRoll your own
Resume (missed events)Built in (Last-Event-ID)Roll your own
Works with HTTP auth / cookies / proxiesYes, nativelyPartially
MultiplexingPer-connection limit on HTTP/1.1; fixed by HTTP/2One connection multiplexes messages

Why SSE Is Underused

Most "we need realtime" requirements are one-directional: live scores, notifications, log tails, LLM token streaming, progress bars, dashboards. For all of these, SSE is less code, survives proxies, resumes cleanly, and rides your existing HTTP auth. Teams reach for WebSockets by reflex and then reinvent reconnection and replay that SSE already ships.
The one real SSE gotcha: on HTTP/1.1, each SSE stream eats one of the browser's ~6 connections per origin. Open several tabs and you starve. Serve it over HTTP/2 (multiplexed streams) and the limit disappears.

Key Takeaways