WebSockets vs SSE vs Polling: Picking a Real-Time Transport
Compare WebSockets, Server-Sent Events and polling for real-time data: reconnection, scaling across instances, proxy buffering, and which to actually pick.
"We need real-time" almost always means "the page should update without the user pressing refresh". Those are different requirements, and conflating them is how a project ends up running a WebSocket cluster to deliver a notification count that changes twice an hour.
Both push transports are well documented and worth comparing directly before choosing: MDN on Server-Sent Eventsand MDN on the WebSocket API. The gap in surface area between those two pages is itself a useful signal about the difference in what you take on.
There are three transports worth considering. The choice comes down to one question, which most comparisons bury: does data need to flow from the client to the server continuously, or only from server to client?
The comparison
| Polling | SSE | WebSockets | |
|---|---|---|---|
| Direction | Client asks | Server to client | Both ways |
| Protocol | HTTP | HTTP | Upgrades away from HTTP |
| Auto-reconnect | Trivially, it is just requests | Built into the browser | You write it |
| Works through strict proxies | Always | Usually | Sometimes not |
| Server cost per client | Nothing between polls | One open connection | One open connection |
| Binary data | Yes | No, text only | Yes |
Read the "direction" row first. If the answer is server to client only, which covers notifications, live dashboards, progress bars, status feeds and streamed AI responses, WebSockets are more machinery than the problem needs.
Polling, which is underrated
Asking again on a timer is the simplest thing that works, and it is the right answer far more often than its reputation suggests. It uses ordinary HTTP, so caching, authentication, load balancing, retries and every debugging tool you own already apply.
The cost is latency and wasted requests. A 30 second interval means data is up to 30 seconds stale, and most of those requests return nothing new.
It stops being appropriate at roughly the point where the interval drops below a few seconds. At one second per client, a thousand users generate a thousand requests per second to tell almost all of them that nothing changed. That is when a push transport starts to pay for itself. The practical details of doing polling well, conditional intervals, stopping when the tab is hidden, are in polling in React with TanStack Query.
Server-Sent Events
SSE is a long-lived HTTP response that the server keeps writing to. It is one-directional, text only, and it is the option most people do not consider.
// Node, framework-agnostic.
export function sseHandler(req: Request, res: Response) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Nginx buffers proxied responses by default, which holds every
// event until the connection closes.
'X-Accel-Buffering': 'no',
});
const send = (event: string, data: unknown, id?: string) => {
if (id) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const unsubscribe = bus.subscribe(req.user.id, (msg) =>
send('update', msg, msg.id)
);
// Comment lines keep intermediaries from closing an idle connection.
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
}const source = new EventSource('/api/events');
source.addEventListener('update', (e) => {
const data = JSON.parse(e.data);
applyUpdate(data);
});
// The browser reconnects on its own, with backoff, and replays the
// Last-Event-ID header so the server can resume from where it stopped.
source.onerror = () => {
console.warn('SSE disconnected, browser will retry');
};That automatic reconnection is the feature. The browser retries on its own and sends a Last-Event-ID header carrying the last id it saw, so a server that tracks event IDs can replay what was missed. With WebSockets you write all of that yourself, and most implementations do not, which is why they lose messages during a network blip.
WebSockets
A WebSocket starts as an HTTP request that upgrades to a persistent bidirectional connection. After the handshake it is no longer HTTP, and that is both the feature and the cost.
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => {
// Authenticate before the upgrade completes. There is no
// per-message auth once the connection is open.
const user = await authenticate(req);
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, user);
});
});
wss.on('connection', (ws, user) => {
// Detect half-open connections. A client that vanished without a
// close frame otherwise stays "connected" indefinitely.
let alive = true;
ws.on('pong', () => { alive = true; });
const interval = setInterval(() => {
if (!alive) return ws.terminate();
alive = false;
ws.ping();
}, 30_000);
ws.on('close', () => clearInterval(interval));
});The ping/pong loop is not optional. TCP connections through NAT and mobile networks die without a close frame, and without heartbeats the server holds thousands of sockets belonging to clients that left hours ago. The symptom is memory that only ever grows.
Socket.IO is not WebSockets
Worth separating, because they get used interchangeably. Socket.IO is a library with its own protocol that happens to use WebSockets as one transport and falls back to HTTP long-polling when it cannot. That fallback is why it earned its reputation, and it matters much less now that WebSocket support is universal.
What you still get is reconnection, acknowledgements, rooms and broadcast, which is most of the code below. What you give up is interoperability: a Socket.IO server does not speak to a plain WebSocket client, in either direction. If you control both ends and want the batteries, use it. If anything else needs to connect, use plain WebSockets.
Who handles reconnection?
function connect(attempt = 0) {
const ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; };
ws.onclose = (event) => {
// 1000 is a normal closure. Do not reconnect after one.
if (event.code === 1000) return;
const delay = Math.min(30_000, 2 ** attempt * 1000) + Math.random() * 1000;
setTimeout(() => connect(attempt + 1), delay);
};
return ws;
}Exponential backoff with jitter, and a check for a deliberate close. Without the jitter, a server restart brings every client back at the same instant and knocks it over again. Without the code check, closing the connection on logout immediately reopens it.
And reconnecting is only half of it. Messages sent while disconnected are gone, so anything that matters needs sequence numbers and a replay mechanism. That is real work, and it is the work SSE gives you free.
How do you scale past one server?
This is the constraint people meet late. A connection lives on one process. With four instances behind a load balancer, a user connected to instance 2 cannot be reached by an event generated on instance 3.
// Every instance subscribes; the one holding the connection delivers.
const sub = redis.duplicate();
await sub.subscribe('events', (raw) => {
const msg = JSON.parse(raw);
const socket = localConnections.get(msg.userId);
socket?.send(raw);
});
// Publishing from anywhere reaches whichever instance holds the client.
export async function notify(userId: string, payload: unknown) {
await redis.publish('events', JSON.stringify({ userId, ...payload }));
}A Redis pub/sub fan-out is the standard answer and applies equally to SSE and WebSockets. It also means sticky sessions stop being required, which is worth having. Redis is doing similar work for API rate limiting, and if you already run it for one you have it for the other.
Serverless platforms are the other constraint. A function with a 10 or 60 second execution limit cannot hold a long-lived connection at all, so both SSE and WebSockets need either an always-on process or a managed service. Polling works fine there, which is occasionally the deciding factor.
How I actually choose
Polling when updates every few seconds are acceptable, or when the deployment cannot hold open connections. It is the default and it is fine.
SSE when the server needs to push and the client only needs to make ordinary requests. Notifications, live dashboards, job progress, and streamed model output, which is exactly the case I used it for in LLM API integration. Reconnection and replay come free and the whole thing is still HTTP.
WebSockets when the client genuinely sends continuously: collaborative editing, multiplayer, live cursors, a chat with typing indicators, anything where round-trip latency is the product. Also when you need binary frames, which SSE cannot carry.
The pattern I would push back on is a WebSocket used purely as a server-to-client push channel, with the client only ever sending pings. That is SSE with extra reconnection code you now maintain. It is the most common over-engineering in this space, and the tell is a codebase with a hand-rolled exponential backoff, a heartbeat, and a message queue that exists to work around dropped connections.
Frequently asked questions
When should I use SSE instead of WebSockets?
Whenever data only flows from server to client: notifications, live dashboards, job progress, status feeds and streamed model output. SSE is ordinary HTTP, so it inherits your existing authentication, compression, proxies and logging without special handling. The browser also handles reconnection with backoff on its own and replays a Last-Event-ID header, so the server can resume from where the client left off. With WebSockets you write all of that yourself, and most implementations do not, which is why they quietly lose messages during a network blip. Reach for WebSockets when the client genuinely needs to push as well, as in chat, collaborative editing or multiplayer. Sending the occasional message from client to server over a normal POST alongside an SSE stream is a perfectly good design and often the simpler one. One practical limit worth knowing: browsers cap concurrent connections per domain over HTTP/1.1, which HTTP/2 largely removes.
Why are my SSE events arriving all at once?
A buffering proxy. Nginx and several CDNs buffer proxied responses by default, holding every event until the connection closes, which makes a working implementation look completely broken. Send X-Accel-Buffering set to no, and include no-transform in the Cache-Control header so intermediaries leave the body alone. If events arrive in a burst rather than as they happen, this is almost always the cause, and it is worth checking before you touch application code. Compression middleware is the other usual suspect, since a gzip stream may hold bytes until it has enough to emit a block. Test through the real proxy chain rather than against the dev server, because this class of bug is invisible locally and appears the day you deploy. Sending a periodic comment line as a heartbeat helps separately, since it keeps idle connections from being closed by intermediaries.
How do WebSockets scale across multiple servers?
A connection lives on one process, so an event generated on instance three cannot reach a user connected to instance two without help. The standard answer is a Redis pub/sub fan-out: every instance subscribes to the relevant channels, and whichever one holds the connection delivers the message to its own clients. That also removes the need for sticky sessions, which is worth having for its own sake, and the same pattern applies equally to SSE. Two things to plan for: pub/sub is fire and forget, so a message published while an instance is restarting is simply lost, and connection counts are a real capacity limit, since each open socket holds memory and a file descriptor whether or not it is carrying traffic. If delivery has to be guaranteed rather than best effort, put a real queue behind it and treat the socket as a delivery mechanism rather than the source of truth.
Is polling ever the right choice?
Often, and more often than its reputation suggests. It uses ordinary HTTP, so caching, authentication, load balancing and every debugging tool you already have apply without modification, and serverless platforms that cannot hold connections open support it perfectly well. There is no connection state to recover after a deploy, which quietly removes a whole category of operational work. It stops being appropriate at roughly the point where the interval drops below a few seconds, since most of those requests return nothing new and you are paying full request overhead for an empty answer. The honest way to decide is arithmetic rather than taste: multiply your user count by the request rate and see whether the number frightens you before you reach for a push transport. Adding conditional requests with ETags is the cheap improvement most polling implementations skip, since an unchanged resource then costs a 304 rather than a full body.


