Pick server-sent events unless the client needs to push messages back down the same connection. SSE is an ordinary HTTP response held open, it reconnects on its own, and it replays what you missed from an event id. WebSockets earn their extra operational cost only when the traffic is genuinely two-way.
Most teams reach the server-sent events vs WebSockets question while building something one-directional: LLM token streams, job progress, a deploy log, a notifications bell. For all of those, the client has nothing to say. Choosing the bidirectional protocol anyway buys you a second protocol to operate and a reconnection layer to write.
What actually differs between the two protocols
SSE never stops being HTTP. You return 200 with Content-Type: text/event-stream and keep writing to the socket. Every proxy, CDN, WAF, auth gateway and tracing agent in the path still sees a normal request with a normal response, because that is what it is.
A WebSocket starts as HTTP and then stops being HTTP. After the Upgrade: websocket handshake, the bytes on that TCP connection are frames in a different protocol. Your load balancer can forward them, but it cannot read them, cache them, or attach a request id to them.
The SSE wire format is four fields and a blank line. data: appends to the payload buffer, event: names the event type (default message), id: sets the last event id, and retry: sets the reconnection delay in milliseconds. A line starting with : is a comment. The spec is blunt about encoding: “Event streams in this specification must always be encoded as UTF-8.” Binary payloads mean base64 and roughly a third more bytes on the wire.
What SSE gives you free that WebSockets make you build
Reconnection with resume. Send retry: 3000 once and the browser waits three seconds before reconnecting. Send id: on each event and the browser puts the last one it saw in a Last-Event-ID request header when it comes back. Your handler reads that header and replays from there. Nobody writes any of it.
The WebSocket API is a constructor, send(), close(code, reason) and four events. No reconnection, no resume cursor, no exponential backoff, no ping/pong exposed to JavaScript. That is why almost every WebSocket app in production is actually a ws (8.21.3) or socket.io (4.8.3) app, and why wrappers like partysocket exist at all.
Backpressure is the other gap. MDN puts it plainly: “The WebSocket API has no way to apply backpressure”, so a fast producer will “fill up the device’s memory by buffering those messages, become unresponsive due to 100% CPU usage, or both”. WebSocketStream fixes this with real streams, but as of August 2026 MDN still records it as experimental and “not currently a part of any specification”. With SSE you are writing to a Node stream, so res.write() returning false and the 'drain' event are the backpressure signal, and they already work.
What breaks server-sent events in production
Four things, roughly in order of how often they bite.
Proxy buffering. nginx sets proxy_buffering on by default, so your events sit in a buffer until it fills or the response ends. The escape hatch is a response header: “Buffering can also be enabled or disabled by passing yes or no in the X-Accel-Buffering response header field”, per the proxy module docs.
Compression. The compression middleware README says it outright: “Because of the nature of compression this module does not work out of the box with server-sent events.” Compression needs a window of output before it emits anything. Either exclude the route or call res.flush() after each event.
Idle timeouts. nginx proxy_read_timeout defaults to 60 seconds, and it is measured “only between two successive read operations”, so a quiet stream gets cut. Cloudflare’s Proxy Read Timeout is 125 seconds by default, raisable to 6,000 on Enterprise. A : keepalive comment every fifteen seconds costs twelve bytes and solves all of it.
The six-connection ceiling. Over HTTP/1.1 the browser caps concurrent connections per origin, and each open tab spends one on your stream. MDN calls it out as a known trap: “the limit is per browser and set to a very low number (6)”, and marked won’t-fix in Chrome and Firefox. Over HTTP/2 the limit becomes the negotiated SETTINGS_MAX_CONCURRENT_STREAMS, typically 100. Serve SSE over HTTP/2 or watch a user with seven tabs open lose the whole app.
Here is an endpoint that survives all four:
import { createServer } from 'node:http';
createServer((req, res) => {
if (req.url !== '/events') {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write('retry: 3000\n\n');
let seq = Number(req.headers['last-event-id'] ?? 0);
const tick = setInterval(() => {
seq += 1;
const payload = JSON.stringify({ seq, at: Date.now() });
res.write(`id: ${seq}\nevent: tick\ndata: ${payload}\n\n`);
}, 1000);
const beat = setInterval(() => res.write(': keepalive\n\n'), 15000);
res.on('close', () => {
clearInterval(tick);
clearInterval(beat);
});
}).listen(3000);
no-transform in Cache-Control is the standards-track way of telling intermediaries to keep their hands off the body. flushHeaders() gets the response line out before the first event, which matters if anything upstream is timing your first byte.
How do you send an auth header with SSE?
You cannot, with EventSource. The constructor takes a URL and exactly one option, withCredentials. GET only, no headers, no request body. Cookies work. Bearer tokens do not, unless you are willing to put one in a query string and therefore in every access log between you and the origin.
The fix is to drop EventSource and parse the stream out of fetch yourself, which also buys you POST, an AbortSignal, and any header you like:
const res = await fetch('/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
body: JSON.stringify({ room: 'ops' }),
signal: controller.signal,
});
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let split: number;
while ((split = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
const data = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
.join('\n');
if (data) handle(JSON.parse(data));
}
}
Two caveats on that loop. It only splits on \n\n, while the spec also accepts \r and \r\n line endings, so normalise the buffer first if you do not control the server. And you have just taken back ownership of reconnection and Last-Event-ID, which was the main thing EventSource was doing for you. Budget for it, or accept the query-string token. @microsoft/fetch-event-source is the usual library here, though 2.0.1 has been its latest release since April 2021.
When are WebSockets the right call?
When the client is a participant rather than an audience. Cursor positions, presence, collaborative document edits, game input, anything where the browser emits at a rate that would make a POST per message absurd. Also when you need binary frames without base64 overhead, or sub-protocol negotiation, or your backend already speaks WebSocket natively through Phoenix Channels, a Yjs provider, or MQTT over WebSocket.
Deployment target changes the maths too. On Cloudflare, the Durable Objects Hibernation API lets an idle connection cost nothing: “Billable Duration (GB-s) charges do not accrue during hibernation”, with acceptWebSocket() in place of accept(), a webSocketMessage() handler, and up to 16,384 bytes of per-connection state carried across the sleep via serializeAttachment(). If you are already on Workers, and our Cloudflare Pages and Workers comparison covers why you probably are, a long-lived WebSocket is cheaper than it looks.
One asymmetry to know if the consumer is a Node service rather than a browser. Node’s WebSocket global has been unflagged since v22.0.0 and stable since v22.4.0. The EventSource global, added in v22.3.0 and v20.18.0, is still Stability 1 and needs --experimental-eventsource. The npm eventsource package (5.1.1) covers the gap.
What I would actually do
Default to SSE for anything server-to-client. One route, one content type, reconnection handled by the browser, and full visibility for every piece of infrastructure between your process and the user. Serve it over HTTP/2, send a keepalive comment, set X-Accel-Buffering: no, and keep it away from any compressing proxy that will not flush. That combination is what most of our API development and integration work ends up shipping for streaming endpoints.
Move to WebSockets when the client genuinely writes, and move the whole feature, rather than running SSE downstream and POSTs upstream and maintaining two failure modes.
What should not decide it is the feeling that WebSockets are more real-time. Both sit on TCP and both deliver inside the same millisecond. The only latency difference either one has in practice comes from what your proxies do to the bytes in between, and that is a configuration problem, not a protocol choice.