You need a live notification badge. The count in the corner should tick up the moment something happens on the server — no refresh, no polling every three seconds hoping you didn't miss anything. So you do what everyone does: npm install socket.io, wire up a client, and ship it.
Then someone's wifi hiccups for two seconds. The socket drops. Nothing reconnects, because you didn't write that part yet. Now you're writing it — a reconnecting flag, a backoff timer that doubles each attempt, a cap so it doesn't retry forever, a check for whether the tab is even in the foreground. Forty-something lines later you have a hand-rolled connection manager sitting in front of a feature that only ever sent data in one direction: server to browser.
There's a browser API that already does the part you just spent an afternoon on. It's been sitting in every evergreen browser for over a decade, and it's not the one you reached for.
The wrong way, in more detail than you wanted
Here's roughly what that WebSocket reconnect logic looks like once it's "done":
let socket;
let retryDelay = 1000;
function connect() {
socket = new WebSocket('wss://api.example.com/notifications');
socket.onopen = () => { retryDelay = 1000; };
socket.onmessage = (event) => {
updateBadge(JSON.parse(event.data));
};
socket.onclose = () => {
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
};
socket.onerror = () => socket.close();
}
connect();
It works. It's also code you didn't want to own: a manual backoff schedule, a retryDelay you have to remember to reset on success, and a close/error interaction that's easy to get subtly wrong (fire both handlers and you'll double-schedule a reconnect). And you still haven't handled resuming — if the connection drops mid-stream, you have no idea what the server sent while you were gone. You just start fresh and hope you didn't miss the one notification that mattered.
None of that is a WebSocket problem, exactly. It's what you get for using a bidirectional, binary-capable protocol for a feature that only ever pushes text from server to client.
What you're actually asking for
Strip the feature down to what it needs: the server pushes updates, the browser listens, and if the connection drops, it should pick back up without you writing a state machine. That's Server-Sent Events — an HTTP response that never ends, plus a browser-side API called EventSource that reads it.
The client side is almost embarrassingly small:
const stream = new EventSource('/api/notifications');
stream.onmessage = (event) => {
updateBadge(JSON.parse(event.data));
};
stream.onerror = () => {
// the browser is already retrying — this just tells you it happened
console.warn('connection dropped, reconnecting…');
};
That's it. No retryDelay, no setTimeout, no connect() function to call again. If the connection drops — the wifi hiccup, a proxy timeout, the server restarting — the browser reconnects on its own. You didn't write that part because it isn't yours to write.
The part that replaces your backoff timer
The server side is a plain HTTP response with one specific content type and a text format the browser knows how to parse:
app.get('/api/notifications', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
const unsubscribe = notifications.subscribe(send);
req.on('close', unsubscribe);
});
Each message is a data: line followed by a blank line — that blank line is what tells the browser "this event is complete, hand it to onmessage." Send that content type and keep the connection open, and you've built the entire server half of what socket.io was doing for you, minus the parts you didn't need.
The reconnection is where it earns the comparison to your backoff timer. When the browser reconnects after a drop, it's not just retrying blindly — it sends a Last-Event-ID header carrying the ID of the last event it successfully received, so the server can pick the stream back up instead of replaying everything or losing the gap:
const send = (data, id) => {
res.write(`id: ${id}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
app.get('/api/notifications', (req, res) => {
res.set({ 'Content-Type': 'text/event-stream' });
const lastId = req.headers['last-event-id'];
const missed = notifications.since(lastId); // your own backlog logic
missed.forEach((n) => send(n.payload, n.id));
const unsubscribe = notifications.subscribe(send);
req.on('close', unsubscribe);
});
You still have to write notifications.since() — SSE gives you the mechanism for catching up, not the backlog itself. But that's a data-layer problem you'd have had with a WebSocket too, except there you'd also be reimplementing the ID and the reconnect trigger by hand.
Want to change how long the browser waits before its first reconnect attempt? The server can set that too, with a retry: line — the value's in milliseconds:
retry: 5000
data: {"count": 3}
Skip it and the browser falls back to its own default of a few seconds, which is fine for most dashboards and badges.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Where this stops being the right tool
SSE isn't a WebSocket replacement — it's the right tool for a narrower job, and it's worth being honest about the edges before you reach for it everywhere:
-
It's one-way. The stream only carries server → client. If the browser needs to send something back — a chat message, a cursor position, a game input — that's a normal
fetchcall on the side, not a message over the same connection. The moment the client needs to talk back in real time, you're back to WebSockets. -
It's text only. Every event is UTF-8. Binary data — audio chunks, protobuf, anything you'd otherwise send as a
BloborArrayBuffer— has to be base64-encoded first, which is exactly the kind of tax that makes WebSockets or WebRTC the better call for that payload. -
HTTP/1.1 caps you at six connections per browser, per origin. Because each
EventSourceholds a request open indefinitely, a user with several tabs open to your app can exhaust that limit and starve other requests on the same domain. Serve your app over HTTP/2 (most CDNs and reverse proxies do this by default now) and the cap disappears, because HTTP/2 multiplexes many streams over one connection — but it's worth checking, not assuming.
If your feature needs the client to send data in the same real-time loop, or needs binary frames, that's your WebSocket. If it's a one-way trickle of small text updates — a badge, a progress indicator, a live dashboard number, a "someone else is editing this doc" banner — you were reaching for the heavier tool.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Next time a feature description is "push updates from the server, nothing coming back," open your network tab before you open your package.json. Check whether EventSource covers it first — you might delete a reconnect handler instead of writing one.
Have you shipped a WebSocket for something that turned out to be one-way the whole time? Tell me what it was — I'd bet it's more common than the socket.io download numbers suggest.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)