DEV Community

Renato Silva
Renato Silva

Posted on

SSE vs WebSockets vs Polling: Real-Time Sync From the Backend

There's been a nice trick going around: use BroadcastChannel in the browser to sync state across tabs without a server round-trip. It's elegant, but it only solves half the problem — it syncs tabs on one device. The moment you have two different users, or one user on a phone and a laptop, you need the server to be the source of truth and push updates out.

So let's flip it: how do you actually push consistent state to N connected clients from a Node API, and which transport should you reach for?

🔧 The Problem

Say you're building something boring and real: a shared cart, a live dashboard, a "someone else is editing this" indicator. Multiple clients need to see the same state change at roughly the same time, without everyone hammering GET /state every 500ms.

You've got three realistic options:

  1. Polling — client asks, server answers, repeat
  2. SSE (Server-Sent Events) — server pushes a one-way stream over plain HTTP
  3. WebSockets — full duplex, server and client both push

Each one has a different cost model, and picking the "cool" one (WebSockets) is often the wrong call.

🐢 Polling: the boring baseline

Polling gets a bad reputation it doesn't fully deserve. It's stateless, trivially horizontally scalable, works through every proxy and CDN ever built, and requires zero special infrastructure.

javascript
// client
setInterval(async () => {
const res = await fetch('/api/state');
const state = await res.json();
renderState(state);
}, 2000);

The honest trade-off: latency is bounded by your interval, and cost scales linearly with (clients × interval). Ten thousand clients polling every 2 seconds is 5,000 requests/sec hitting your server even when nothing changed. Fine for a demo, painful at scale, and it never actually feels "real-time" — there's always a visible lag.

📡 SSE: push, but only one way

SSE is the underrated option. It's just an HTTP response that never closes, with a text protocol on top. No new protocol, no special client library, works over regular HTTP/1.1 and HTTP/2, and reconnects automatically via EventSource.

Here's a minimal Node/Express version that keeps a registry of connected clients and broadcasts state changes:

javascript
import express from 'express';
const app = express();

let state = { count: 0 };
const clients = new Set();

app.get('/events', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();

// send current state immediately so late joiners aren't out of sync
res.write(data: ${JSON.stringify(state)}\n\n);

clients.add(res);
req.on('close', () => clients.delete(res));
});

function broadcast(newState) {
state = newState;
const payload = data: ${JSON.stringify(state)}\n\n;
for (const res of clients) res.write(payload);
}

app.post('/increment', express.json(), (req, res) => {
broadcast({ count: state.count + 1 });
res.sendStatus(204);
});

app.listen(3000);

javascript
// client
const source = new EventSource('/events');
source.onmessage = (e) => renderState(JSON.parse(e.data));

That's the whole system. No socket library, no handshake upgrade dance, no ping/pong heartbeat logic to babysit — the browser handles reconnects for you.

The catch: SSE is one-directional. Clients still need a normal POST/fetch to send actions back. For a lot of real apps (dashboards, notifications, live scores, cart sync) that's not a limitation, it's a feature — you get a clean separation between "write path" (REST) and "read/subscribe path" (SSE).

Also worth knowing: browsers cap concurrent EventSource connections per origin (6 over HTTP/1.1), and some corporate proxies buffer streaming responses, which can delay delivery. HTTP/2 mostly fixes the connection-limit problem since it multiplexes over one TCP connection.

🔌 WebSockets: when you actually need two-way

WebSockets are the right tool when the client needs to push frequently too — collaborative editing, multiplayer cursors, chat, game state. Otherwise they're often overkill: you now own a stateful, bidirectional connection with your own reconnect logic, your own heartbeat, and your own message framing.

javascript
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });

let state = { count: 0 };

wss.on('connection', (ws) => {
ws.send(JSON.stringify(state));

ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'increment') {
state = { count: state.count + 1 };
const payload = JSON.stringify(state);
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(payload);
}
}
});
});

This works fine on one process. The real cost shows up when you scale horizontally: connections are pinned to whichever server instance accepted them, so a broadcast has to fan out across processes too — usually via Redis pub/sub, NATS, or a managed service like Pusher/Ably. That's infrastructure SSE and polling don't force on you nearly as early.

📊 Honest comparison

Polling SSE WebSockets
Direction client-pull server-push (one-way) bidirectional
Transport plain HTTP plain HTTP (streamed) own protocol over TCP
Reconnect handling trivial (just retry) built into EventSource you build it
Horizontal scaling trivial (stateless) needs shared client registry needs pub/sub fan-out
Proxy/firewall friendliness best good can be blocked/downgraded
Good fit low-frequency, infrequent updates dashboards, notifications, live state chat, collab editing, multiplayer

A pattern I keep coming back to: start with SSE for anything that's fundamentally "server tells clients what changed." Only reach for WebSockets once you have a genuine, frequent client-to-server-to-other-clients requirement that a POST + SSE combo can't express cleanly. Polling is still the right call for admin dashboards or anything where a few seconds of staleness is genuinely fine and you'd rather not run a persistent-connection service at all.

🧠 The part that actually matters: consistency, not transport

Here's the thing none of the three options solve for you: what happens when two broadcasts race, or a client reconnects mid-update and misses a message? The transport is the easy 20%. The hard part is designing your broadcast payload so a client can always recover a consistent view — either by sending full state snapshots (like the example above) instead of deltas, or by including a version/sequence number so clients can detect gaps and request a resync.

If you only ever broadcast diffs, a single dropped message means every client after it is silently wrong forever. That's the bug that doesn't show up in your demo and absolutely shows up in production three weeks later.

What's your default pick for this kind of problem — do you reach for SSE first, or do you go straight to WebSockets out of habit? Curious how many people are still shipping raw polling in 2024 and just not talking about it.

Top comments (0)