Someone on the project says "live updates" and someone else says "WebSockets," because that is the famous answer. Then the feature is a notification feed: strictly server-to-client, one event every few minutes, behind a corporate proxy that strips Upgrade headers. The famous answer would have been the wrong one. The right question is not which channel is best, it is which direction the data flows and what your intermediaries allow.
The constraint
A realtime channel has to deliver server events with bounded latency, survive what sits between client and server (load balancers, proxies, mobile radios that suspend TCP), and fail in a way the on-call engineer can diagnose. The candidates all clear the browser-support bar, so support is not the deciding factor. Direction, reconnection, and infra are.
The options
Polling. Short polling is a request on an interval; long polling holds the request open until data arrives or it times out. Every message pays full HTTP overhead, headers and auth included, and long polling holds a connection open per waiting client, plus a worker on thread-per-connection servers. In exchange it gets the one property nothing else matches: it is indistinguishable from ordinary traffic, so it traverses proxies, CDNs, and firewalls as ordinary traffic.
Server-sent events. One GET that stays open, text/event-stream response, server writes events down it forever. MDN states the direction plainly: "This is a one-way connection, so you can't send events from a client to a server." In exchange for that limit the spec hands you reconnection for free: the WHATWG standard mandates that "clients will reconnect if the connection is closed," sends the Last-Event-ID header on reestablish, and lets the server tune the delay with a retry: field. You write zero reconnect code.
WebSocket. An HTTP Upgrade handshake (a GET with Upgrade: websocket, answered 101 Switching Protocols), and then, per RFC 6455, "an independent TCP-based protocol. Its only relationship to HTTP is that its handshake is interpreted by HTTP servers as an Upgrade request." Both sides send messages at will, text or binary, with ping/pong control frames for keepalive. What the spec does not define is any reconnection procedure: a dead socket is a CLOSED readyState and a problem your code owns.
The mechanism
SSE is a text format you can read with your eyes. This is a complete endpoint; save it as server.mjs and run node server.mjs, then curl localhost:8080 watches the stream:
// a complete sse endpoint in node; no dependencies
import { createServer } from "node:http";
createServer((req, res) => {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
});
res.write(": keepalive\n\n"); // comment beats idle timeouts
let n = 0;
const timer = setInterval(() => {
n += 1;
res.write(`id: ${n}\ndata: {"tick":${n}}\n\n`);
}, 1000);
req.on("close", () => clearInterval(timer));
}).listen(8080);
Every message is named fields and a blank line: data: for the payload, id: to seed Last-Event-ID, event: for a named type, retry: to set the reconnect delay. A line starting with a colon is a comment, and MDN notes it exists partly so "a server can send a comment periodically to keep the connection alive." The client side is three lines: new EventSource(url), onmessage, addEventListener for named events.
Where it bites
WebSocket connections die quietly and nothing resumes them. There is no Last-Event-ID equivalent and no reconnect in the API; you build backoff, jitter, deduplication, and a resume token or clients silently miss messages. Idle sockets get reaped by middleboxes, which is why the protocol carries ping/pong frames: RFC 6455 says a ping serves "as a keepalive or as a means to verify that the remote endpoint is still responsive." Then there is the proxy problem: Upgrade is a hop-by-hop header, and nginx's own WebSocket docs exist because "special processing on a proxy server is required" to pass it. wss:// tunnels through CONNECT and fixes most of this; a MITM-inspecting corporate proxy can still break it. MDN adds one more honest limitation: the browser WebSocket API "has no way to apply backpressure."
SSE's ceiling is the connection count, not the protocol. Per the MDN warning box: when SSE is not used over HTTP/2, connections are capped at 6 "per browser + domain" across all open tabs, and "the issue has been marked as 'Won't fix' in Chrome and Firefox." Over HTTP/2 the cap is the negotiated stream limit, which defaults to 100. Serve H2 and the ceiling effectively disappears; serve H1 and three open tabs eat half your quota. The other cost is resume semantics: Last-Event-ID only helps if your server keeps a replay buffer, otherwise reconnecting clients skip events that fired while they were down.
Polling pays per message but ships nothing. For sparse events, minutes apart, an always-open socket is overhead in both directions: keepalive traffic, connection state on the server, reconnect logic on the client. A request every 30 seconds is boring, cheap, and works through infrastructure that would eat an Upgrade header.
The numbers
- Browser support is a wash: EventSource and WebSocket both sit around 97% global support per caniuse, and MDN marks both Baseline. Pick on fit, not support.
- WebSocket control frames (close, ping, pong) are capped at 125 bytes of payload and MUST NOT be fragmented, per RFC 6455 section 5.5.
- RFC 6455 dates to December 2011; the EventSource reconnection contract lives in the WHATWG HTML standard and is a browser guarantee, not a library feature.
Our position
Default to SSE for server-push: notifications, feeds, dashboards, progress bars, anything where the client mostly listens. Reach for WebSocket only when client-to-server messages are frequent and latency-sensitive: collaborative cursors, multiplayer state, terminal sessions, typing indicators. Keep polling for sparse events and for the fallback path when an Upgrade never survives the client's network. Direction decides first; your proxies get a veto second.
The takeaway
progress. auto-reconnect + last-event-id free
needs http/2 for scale: h/1.1 caps 6 per domain
you own reconnect, resume, dedupe, backoff
ping/pong keepalive; upgrade is hop-by-hop,
proxies need explicit config; no backpressure
networks; per-message cost is the price
buffer; ws has none, design the token yourself
The channel choice is an architecture decision we make explicitly on custom software builds rather than inherit from a tutorial, because the wrong one is cheap to pick and expensive to operate. If your realtime feature is dropping messages or your sockets cannot survive a deploy, send us the topology.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
