Real-time connections
fetch answers one question at a time. This is what answers a stream of them.
Nothing below is a .try block — 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 |
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).
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.
EventSource
is simpler and reconnects itself for free.
Long polling
async function poll() {
try {
const response = await fetch("/api/updates?wait=30"); // server HOLDS this request open until there's something to say
const data = await response.json();
handleUpdate(data);
} finally {
poll(); // immediately ask again — the "long" part is the server delaying its response, not the client waiting between requests
}
}
poll();
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();Opens in the editor — write it, run it, and check it against real tests.