I8 · Part 2 · Intermediate
Real-time connections
fetch answers one question at a time. This is what answers a stream of them.
05On this page
Nothing below is a runnable example — every option here needs a real server on the other end, so a live demo in this sandbox would either hang or fail for reasons that have nothing to do with the code being right or wrong. The one genuinely testable piece — reconnect backoff — gets its own runnable example further down.
WebSocket
A single, long-lived, two-way connection — either side can send a message at any time, with no request/response pairing required. It starts as a normal HTTP request that asks to upgrade the connection; once the server agrees, the same TCP connection stops speaking HTTP and starts speaking the WebSocket frame format instead.
const socket = new WebSocket("wss://example.com/chat");
socket.onopen = () => socket.send(JSON.stringify({ type: "join", room: "general" }));
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log("received:", msg);
};
socket.onerror = (event) => console.error("socket error", event);
socket.onclose = (event) => console.log("closed:", event.code, event.reason);
socket.send(JSON.stringify({ type: "message", text: "hi" }));
socket.readyState | Means |
|---|---|
0 — CONNECTING | handshake in progress |
1 — OPEN | ready — the only state .send() actually works in |
2 — CLOSING | closing handshake started |
3 — CLOSED | fully closed, or never connected at all |
⚠ A WebSocket does not reconnect itself
A dropped connection — the server restarts, a phone switches from
wifi to mobile data — just fires
onclose and stops
there. Every real WebSocket client reconnects manually: catch
onclose, wait (ideally with backoff — see below), and
open a fresh new WebSocket(url).
Dry run: readyState through the socket's lifecycle
| Step | Event / call | readyState before | readyState after | What happens |
|---|---|---|---|---|
| 1 | new WebSocket("wss://example.com/chat") | — | 0 (CONNECTING) | handshake starts; nothing has been sent yet |
| 2 | handshake completes | 0 | 1 (OPEN) | onopen fires and sends the "join" message |
| 3 | socket.send(JSON.stringify({ type: "message", text: "hi" })) | 1 | 1 | succeeds — .send() only works while OPEN |
| 4 | server pushes a message | 1 | 1 | onmessage fires with the parsed payload |
| 5 | either side starts closing | 1 | 2 (CLOSING) | closing handshake in progress; a .send() now throws |
| 6 | closing handshake finishes | 2 | 3 (CLOSED) | onclose fires with a code and reason — nothing reconnects on its own |
readyState only ever moves forward through these four numbers, never backward — once a socket reaches CLOSED there is no "reopen," only constructing a brand-new WebSocket.
Server-Sent Events (EventSource)
The one-directional version — server to client only, over a plain
HTTP response the server keeps open and keeps writing to. The
browser's built-in EventSource handles the entire
protocol, including something WebSocket makes you build yourself:
automatic reconnection.
const events = new EventSource("/api/notifications");
events.onmessage = (event) => console.log("update:", event.data);
events.addEventListener("user-joined", (event) => { // named events, sent as "event: user-joined" in the stream
console.log("joined:", JSON.parse(event.data));
});
events.onerror = () => console.log("connection lost — EventSource is already retrying on its own");
If the connection drops, EventSource automatically
retries on its own — no manual reconnect logic needed at all, which
is the entire trade EventSource makes for giving up the
"client can send things too" half of WebSocket.
Rule Need the client to send data too?
WebSocket. Only need the server pushing updates —live scores, a
notification feed, streaming a long response?
EventSource
is simpler and reconnects itself for free.
Long polling
async function poll(attempt = 0) {
try {
const response = await fetch("/api/updates?wait=30"); // server HOLDS this request open until there's something to say
if (!response.ok) throw new Error("HTTP " + response.status);
handleUpdate(await response.json());
poll(); // success: ask again immediately — the "long" part is the server delaying its response, not the client waiting
} catch {
setTimeout(() => poll(attempt + 1), backoffDelay(attempt)); // failure: back off instead of hammering a server that's down
}
}
poll();
backoffDelay is built at the end of this chapter — the
same wait-longer-after-each-failure rule applies to every reconnect
strategy here, long polling included.
Ordinary polling means asking every N seconds regardless of whether anything changed. Long polling flips who waits: the client asks, and the server simply doesn't answer until it has something to say (or a timeout passes) — one request, held open, instead of hundreds of empty ones. It predates both options above and is strictly worse than either when they're available — but it's plain HTTP, so it still works through the rare restrictive proxy or old environment that blocks a WebSocket upgrade or doesn't handle a held-open SSE stream well.
Deciding between the three
| WebSocket | SSE / EventSource | Long polling | |
|---|---|---|---|
| Direction | both ways | server → client only | both, but request/response shaped |
| Reconnects itself | no — build it yourself | yes, built in | it's just fetch — call it again |
| Good fit | chat, multiplayer, collaborative editing | live feeds, notifications, progress updates | fallback when neither above is available |
| Overhead | lowest per message, once connected | low, plain HTTP | a full request every cycle |
Reconnecting without hammering the server
Reconnecting instantly, every time, in a loop is exactly how a client turns a brief server hiccup into a self-inflicted denial of service against that same server the moment it comes back. The fix is the same shape as the retry pattern from a few chapters back: wait longer after each consecutive failure.
function backoffDelay(attempt, base = 500, max = 30000) {
const exp = Math.min(base * 2 ** attempt, max); // doubles each attempt, capped
return Math.round(exp / 2 + Math.random() * (exp / 2)); // jitter: random within the top half of the range
}
for (let attempt = 0; attempt < 6; attempt++) {
console.log("attempt", attempt, "->", backoffDelay(attempt), "ms");
}
Run it — the delay roughly doubles each attempt, then flattens out at the cap. The randomness (jitter) matters as much as the growth: without it, every client that dropped at the same moment (a server restart takes the whole fleet down at once) reconnects at exactly the same moment too, arriving as a synchronized thundering herd instead of a spread-out trickle.
let attempt = 0;
function connect() {
const socket = new WebSocket(url);
socket.onopen = () => { attempt = 0; }; // reset the counter once a connection actually succeeds
socket.onclose = () => {
setTimeout(connect, backoffDelay(attempt));
attempt++;
};
}
connect();
Interview answer, one sentence
"Polling asks repeatedly, Server-Sent Events push text one way and reconnect on their own, and WebSockets are two-way but need their own reconnection with backoff and jitter — pick the simplest that fits, and always plan for the connection dropping."
Before you move on
- List the four
readyStatevalues in order, and say why a socket can never go backward through them. - Explain why
EventSourcereconnects automatically but a plainWebSocketdoes not. - Explain how long polling differs from ordinary interval polling, and why it's a fallback rather than a first choice.
- Implement exponential backoff with jitter, and explain why the jitter matters as much as the doubling.
- Given a feature — chat, a live notification feed, a fallback for a restrictive proxy — pick the right one of the three and justify it.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series