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.
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.
Reconnection is your problem
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.
Scaling 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, and the browser handles reconnection with backoff and replays a Last-Event-ID header so the server can resume. With WebSockets you write all of that yourself, and most implementations do not, which is why they lose messages during a network blip.
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. Send X-Accel-Buffering: no and include no-transform in the Cache-Control header. If events arrive in a burst rather than as they happen, this is almost always the cause.
How do WebSockets scale across multiple servers?
A connection lives on one process, so an event generated on instance 3 cannot reach a user connected to instance 2. The standard answer is a Redis pub/sub fan-out: every instance subscribes, and whichever holds the connection delivers the message. That also removes the need for sticky sessions, and it applies equally to SSE.
Is polling ever the right choice?
Often. It uses ordinary HTTP, so caching, auth, load balancing and every debugging tool already apply, and serverless platforms that cannot hold open connections support it fine. It stops being appropriate at roughly the point where the interval drops below a few seconds, since most of those requests return nothing new.


