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:
- Polling — client asks, server answers, repeat
- SSE (Server-Sent Events) — server pushes a one-way stream over plain HTTP
- 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)