← blog

SSE vs WebSockets: the simpler real-time you usually want

Reach for WebSockets and you sign up for a connection upgrade, a separate protocol, ping/pong keepalives, and your own reconnection logic. For the most common real-time case — a server pushing updates to a browser — you need none of that. Live logs, progress bars, notifications, price ticks, build status: these are one-directional streams, and Server-Sent Events carry them over plain HTTP with reconnection built in.

What SSE actually is

An SSE endpoint is an ordinary HTTP response that never ends. The server sends Content-Type: text/event-stream, keeps the connection open, and writes events as they happen. The browser side is the EventSource API, which is about as small as web APIs get:

const es = new EventSource('/api/bus/my-channel');
es.onmessage = (e) => console.log(e.data);

That is the whole client. No handshake to manage, no framing to parse. Because it is just an HTTP GET, it travels through the same proxies, CDNs, and corporate middleboxes that already pass your other requests — which is often exactly where a WebSocket upgrade quietly fails.

The wire format is plain text

The text/event-stream body is UTF-8 text: one field per line, with a blank line ending each event. The full vocabulary is four fields.

  • data: — the payload. Consecutive data: lines are joined with newlines between them, so multi-line messages are trivial.
  • event: — a named event type. Without it the message fires onmessage; with it you listen via addEventListener('name', ...).
  • id: — sets the connection's last event ID. This is the field that makes reliable reconnection possible.
  • retry: — reconnection delay in milliseconds. A line starting with a colon is a comment, commonly sent as a keepalive ping.

A minimal event on the wire is data: hello followed by a blank line. You can watch one arrive with curl, since there is no special client involved:

curl -N https://exl.ink/api/bus/my-channel

Reconnection is the part you get for free

With a raw WebSocket, a dropped connection is your problem: you write the backoff, the retry, and the logic to work out what you missed. EventSource reconnects on its own. When it does, if the server had sent any id: fields, the browser includes the last one as a Last-Event-ID request header on the new connection. The WHATWG HTML specification defines this precisely — the user agent stores a last event ID string and sets it on reconnect — so a server that tags events with IDs can resume from where the client left off instead of replaying everything or dropping it.

The reconnection logic you would otherwise write by hand is the protocol's default behavior. That is most of the reason to prefer SSE for server-to-client streams.

When WebSockets are actually the right call

SSE is one direction, server to client, text only. If your problem genuinely needs the other direction or raw bytes, use WebSockets, which open a two-way channel and carry binary frames. The honest test:

  • Low-latency client-to-server messaging in both directions — multiplayer games, collaborative cursors, live drawing, voice signaling. WebSockets.
  • Binary payloads — audio, video chunks, protobuf. WebSockets.
  • Server pushes updates, client occasionally acts, and a normal HTTP POST handles those actions. SSE, almost always.

Note the third case. "The client needs to send things too" does not automatically mean WebSockets. If the client sends rarely, a plain POST alongside an SSE stream is simpler than maintaining a bidirectional socket — sending text to a server is what regular HTTP already does well.

The one real caveat: HTTP/1.1's connection limit

Each open SSE stream holds a connection. Over HTTP/1.1, browsers cap parallel connections at roughly six per origin, and that budget is shared across tabs, not per tab. An SSE stream occupies one of the six the entire time it is open. Open your app in several tabs, each with its own stream, and you can exhaust the budget; further requests to that origin stall until one closes. Chrome has had an open bug about this since 2014 and has declined to raise the limit.

The fix is HTTP/2 (or HTTP/3). HTTP/2 multiplexes many streams over a single TCP connection, with a per-connection stream limit negotiated between client and server — Chrome and Firefox both default to 100. That turns the six-connection cap into a non-issue. If you serve SSE in production, serve it over HTTP/2; nearly every CDN and reverse proxy does so by default now. It is worth knowing the limit exists, so a confusing "why did my seventh tab freeze" bug does not cost you an afternoon.

A pub/sub channel to feel it work

Our pub/sub channel is an SSE stream you can poke at directly. Subscribe to a channel id and anyone who POSTs to it reaches every subscriber, in order, live — fan-out for a quick shared log, agent coordination, or a progress feed across two machines. The browser page uses EventSource; from a terminal it is two curl commands, one to subscribe and one to publish, no client library either way:

curl -d 'hello' https://exl.ink/api/bus/my-channel   # publish to every subscriber

It tags each message with an id: and keeps a small ring of the last ten, so a subscriber that drops and reconnects sends Last-Event-ID and gets only what it missed — the spec behavior above, doing its job. But like everything here it is ephemeral: the ring holds ten messages, nothing is written to disk, and a server restart drops open streams and the buffer alike, leaving subscribers to reconnect into a fresh channel. It is a fan-out bus with a short memory, not a durable queue. For a live stream you are watching now, that is the point; for guaranteed delivery of older messages, reach for something that persists.

Further reading