In the most careful WebSockets vs SSE vs long polling benchmark I've found this year, WebSockets won. A raw WebSocket put 119,692 bytes on the wire for 1,000 events, where SSE needed 131,596. On a Node server, each idle WebSocket held about 13 KB of memory and each SSE connection about 23 KB.
I'd still pick SSE (server-sent events) for most of the real-time features I see people build with Socket.IO. The numbers are below, along with why I don't think they change the answer and the nginx and load balancer settings I use to keep SSE up in production.
Most "real-time" features only push one way
Count how many of these only push data from server to browser:
- notification badges
- live dashboards and metrics
- build/deploy logs tailing in a browser tab
- order status, delivery tracking
- LLM token streaming
All of them. The client says something once, with a normal HTTP request, and then listens. Opening a full-duplex socket for that means paying for a lane nobody drives in.
LLM streaming is the funny one. Plenty of teams wire up Socket.IO to stream tokens from a backend that is itself receiving those tokens over SSE. Anthropic's Messages API streams with "stream": true using server-sent events. OpenAI's streaming guide covers "HTTP streaming (stream=true) over server-sent events (SSE)". (OpenAI also offers a separate WebSocket mode for the Responses API, so it isn't SSE-only, but SSE is the default path.) So the upstream is already one-way HTTP, and converting it to a socket for the last hop just adds a second protocol to maintain.
The benchmark: bytes, latency and memory per connection
The best public measurement I know of is WebSocket vs SSE vs Long Polling: The Real Cost of 1,000 Events on The Infinity. Setup: Node v26.6.0, ws 8.21.3, one process serving the same stream three ways, and a TCP proxy counting payload bytes. The stream was 1,000 events averaging 116.9 bytes of JSON, with compression off.
| Transport | Total bytes | × payload | HTTP requests |
|---|---|---|---|
| WebSocket (HTTP/1.1) | 119,692 | 1.02 | 0 |
| SSE (HTTP/1.1) | 131,596 | 1.13 | 1 |
| SSE (HTTP/2) | 134,534 | 1.15 | 1 |
| Long polling (HTTP/2) | 182,475 | 1.56 | 1,000 |
| Long polling (HTTP/1.1, keep-alive) | 884,698 | 7.57 | 1,000 |
| WebSocket + permessage-deflate | 30,434 | 0.26 | 0 |
SSE costs about 10% more bytes than a raw WebSocket. The author breaks it down to 14.2 bytes per event: 8 for the data: prefix and blank-line terminator, 6 for HTTP/1.1 chunked encoding wrapping every write. WebSocket frames are tiny: per RFC 6455 the header is 2 bytes minimum, 14 maximum.
Long polling over HTTP/1.1 is where it gets ugly. 569,890 of those 884,698 bytes were request headers, re-sent 1,000 times. HTTP/2's HPACK cut per-request headers from 570 bytes to 36, which is why long polling over h2 suddenly looks reasonable.
Latency with a simulated 50 ms RTT:
| Scenario | Mean | p95 |
|---|---|---|
| WebSocket, 1 event/20 ms | 26.16 ms | 26.82 ms |
| SSE, 1 event/20 ms | 26.29 ms | 27.00 ms |
| Long polling, 1 event/20 ms | 52.44 ms | 76.96 ms |
WebSocket and SSE are basically tied. Long polling doubles once events arrive faster than one round trip, because the client can't hear anything while a response is in flight.
Memory, 500 idle connections, server RSS:
| Transport | Per connection |
|---|---|
WebSocket (ws) |
~13 KB |
| SSE | ~23 KB |
| Long polling (parked request) | ~21 KB |
So WebSocket wins on memory too, at least in Node. The author is upfront that these ratios are Node-specific and may differ in Go or Rust, and that CPU, TLS cost and anything past 500 connections weren't measured. Other per-connection memory figures floating around online contradict each other and mostly aren't traceable to a real measurement, so I'm ignoring them.
Now do the arithmetic: 10 KB extra per connection is about 100 MB at 10,000 concurrent users. If that's your bottleneck, you have a nice problem. For most dashboards it's a rounding error next to the cost of running a second protocol through your whole stack.
What SSE's extra 10% buys you
Reconnection you don't write
EventSource reconnects on its own. The WHATWG spec lets the server set the delay with a retry: field, and on reconnect the browser sends a Last-Event-ID header with the last id: it saw. Resume-from-where-I-dropped is built in. With raw WebSockets you write it yourself, which is a big part of why people reach for Socket.IO in the first place.
No sticky-session tax
Socket.IO fans tend to skip this part. By default the Socket.IO client starts on HTTP long-polling and upgrades to WebSocket afterwards, because corporate proxies break WebSockets and a failed attempt can delay the connection by up to 10 seconds. Because of that polling phase, the multi-node docs require sticky sessions: "all requests associated with a particular session ID reach the process that originated them." You can drop sticky sessions by forcing transports: ['websocket'] on the client, and the docs say so, but then you've given up the fallback that was the reason to use Socket.IO. The library chose reliability by default, which is reasonable. It does mean your load balancer config now depends on your notification badge.
An SSE stream is one long HTTP response. Any node can serve it, and when it drops, the reconnect can land anywhere. You still need fan-out behind it (Redis pub/sub, NATS, whatever), but you need that with WebSockets too.
It's just HTTP
Your auth middleware, logging, curl -N, and the browser's network tab all work on SSE unchanged. Proxies and CDNs treat it as a slow response, so you don't have to worry about Upgrade headers getting stripped.
The SSE 6-connection limit only bites on HTTP/1.1
The classic objection: browsers only allow 6 SSE connections. MDN confirms that's real over HTTP/1.1. It's per browser plus domain, shared across all tabs, and marked "Won't fix" in Chrome and Firefox. Open seven tabs of your dashboard and the seventh hangs.
Over HTTP/2 the limit is negotiated and defaults to 100 streams. If you're serving anything in 2026 without h2 at the edge, fix that first. SSE stuck on HTTP/1.1 is a config problem on your side, and blaming the protocol for it is a stretch.
Make SSE survive nginx and AWS ALB
Most "SSE doesn't work behind our proxy" stories come down to three defaults.
- nginx buffers responses.
proxy_bufferingisonby default, so your events sit in a buffer. SendX-Accel-Buffering: nofrom the app or turn buffering off for the route. - Idle timeouts kill quiet streams. nginx's
proxy_read_timeoutdefaults to 60s, and so does the AWS ALBidle_timeout.timeout_seconds. The spec suggests a comment line "every 15 seconds or so". - HTTP/1.1 to the browser. See above.
A Node server that handles all three:
import http from 'node:http';
http.createServer((req, res) => {
if (req.url !== '/events') return res.writeHead(404).end();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
let id = Number(req.headers['last-event-id']) || 0;
const tick = setInterval(() => {
id++;
res.write(`id: ${id}\ndata: ${JSON.stringify({ n: id })}\n\n`);
}, 1000);
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
req.on('close', () => {
clearInterval(tick);
clearInterval(ping);
});
}).listen(3000);
The nginx side:
location /events {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 1h;
}
The client is two lines:
const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
One real gotcha: the EventSource constructor takes a URL and a withCredentials flag, and that's it. You can't set custom headers. If your auth is a bearer token in Authorization, either move to cookies or use fetch() and read the stream yourself, which is what the LLM SDKs do.
When WebSockets actually win
I still reach for WebSockets when:
- the client sends often: multiplayer games, collaborative cursors, live editing
- you need binary. SSE is UTF-8 only, and base64 adds 33%
- you're bandwidth-bound on compressible payloads. permessage-deflate got 74% off in that benchmark
- you're holding a huge number of mostly idle connections in Node and the ~10 KB per connection gap shows up on the bill
WebSockets, SSE or long polling: a decision table
| Your feature | Pick | Why |
|---|---|---|
| Notifications, feeds, dashboards | SSE | One-way, auto-reconnect, plain HTTP |
| LLM token streaming | SSE (or fetch stream) |
The upstream API already speaks it |
| Chat with typing indicators | WebSocket | Frequent client → server |
| Games, cursors, collaborative editing | WebSocket | Bidirectional, latency-sensitive, often binary |
Under ~1 event/sec behind an h2 edge, or Upgrade gets stripped |
Long polling | 1.56× payload over HTTP/2 is fine |
| Anything over HTTP/1.1 | Fix your edge first | 6 connections per domain, shared across tabs |
If your team is arguing about the 10% byte overhead, the meeting has probably cost more than the bandwidth ever will.
What's the last feature you built with WebSockets that SSE could have handled? And if you've had SSE fall over in production, what killed it? I want to hear about the proxy.
Sources
- The Infinity, WebSocket vs SSE vs Long Polling: The Real Cost of 1,000 Events
- WHATWG HTML spec: Server-sent events
- MDN: EventSource
- RFC 6455, section 5.2
- Socket.IO docs: How it works, Using multiple nodes
- nginx ngx_http_proxy_module
- AWS Application Load Balancer attributes
- Anthropic streaming docs, OpenAI streaming guide
Top comments (0)