Real-time: WebSockets
Two-way messaging, lifecycle, and reconnect patterns.
You've built a comments page. New comments appear when someone refreshes — which is fine for a blog and terrible for a chat. The problem is that HTTP only works one way: the client asks, the server answers. There's no way for the server to push a new comment without the client asking first. WebSockets is the standard way around that. Once a WebSocket is open, both sides can send messages whenever they have something to say.
A WebSocket is a single, long-lived TCP connection that upgrades from an HTTP request. After the upgrade, both ends can send text or binary messages until somebody hangs up. The mental model is closer to a phone call than a pile of letters.
Opening a connection
new WebSocket(url) opens a connection. The URL uses ws:// or wss:// (WebSocket Secure) — the secure version is what you'll use in production, exactly like https.
const socket = new WebSocket("wss://chat.example.com/room/42");
socket.addEventListener("open", () => {
console.log("connected");
socket.send("hello");
});Construction begins immediately. The browser does an HTTP request that asks the server "want to upgrade to WebSocket?", and if the server agrees, the connection switches protocols. The open event fires once that handshake succeeds.
Until open fires, calling socket.send() throws — the channel isn't ready. Wait for the event, then send.
Don't write code that calls send immediately after new WebSocket(...). Wait for the open event, or queue messages and flush them inside the handler. The handshake takes a round-trip.
Sending and receiving
socket.send(data) accepts a string, an ArrayBuffer, or a Blob. Strings are sent as text frames; the others as binary. There's no automatic JSON serialization — if you want to send an object, JSON.stringify it yourself.
function sendMessage(socket, payload) {
socket.send(JSON.stringify(payload)); /* always serialize */
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data); /* always parse */
console.log("received", message);
});event.data is whatever the server sent — a string for text frames, a Blob (or ArrayBuffer if you set socket.binaryType = "arraybuffer") for binary. JSON-over-WebSocket is the most common shape: each message is a small JSON object with a type field so both sides know how to interpret it.
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "comment": addComment(msg.comment); break;
case "typing": showTyping(msg.user); break;
case "presence": updatePresence(msg.list); break;
}
});Each message handler is short. The type field doesn't exist in the protocol — it's a convention you and the server agree on. Both ends speak the same little vocabulary.
const socket = new WebSocket(url); socket.send("hi"); immediately. The console shows "InvalidStateError: still in CONNECTING state". What's the fix?The four lifecycle events
A WebSocket goes through a small state machine. Four events tell you where you are:
open— handshake succeeded, connection is alive. Send-able now.message— the server sent something. Fires once per message.error— something went wrong. Fired beforeclosewhen the disconnect was abnormal. The event itself doesn't carry useful detail; treat it as "expect a close next".close— the connection ended, cleanly or otherwise. The event hasevent.code(a number) andevent.reason(a string).
socket.addEventListener("open", () => setStatus("connected"));
socket.addEventListener("message", (e) => onMessage(e.data));
socket.addEventListener("error", () => setStatus("error"));
socket.addEventListener("close", (e) => {
setStatus(`closed: ${e.code} ${e.reason}`);
scheduleReconnect();
});A few common close codes worth recognizing:
- 1000 — normal closure. Both sides agreed.
- 1001 — going away (e.g. the server is restarting, or the user navigated away).
- 1006 — abnormal closure (the connection dropped without a proper close). You'll see this on flaky networks.
You don't usually act on the code itself in app code — close is your signal to retry, regardless.
Reconnect with backoff
Connections drop. The user goes through a tunnel, the server restarts, the laptop closes its lid. A real WebSocket client reconnects on close, but it doesn't reconnect immediately — that would hammer the server when it's already in trouble.
The standard pattern is exponential backoff with jitter: each retry waits longer, and a random offset prevents every client reconnecting at the same instant.
function connect(url, onMessage) {
let attempts = 0;
let socket;
function open() {
socket = new WebSocket(url);
socket.addEventListener("open", () => { attempts = 0; });
socket.addEventListener("message", (e) => onMessage(JSON.parse(e.data)));
socket.addEventListener("close", () => {
attempts++;
const delay = Math.min(30000, 2 ** attempts * 500);
const jitter = Math.random() * 250;
setTimeout(open, delay + jitter);
});
}
open();
return () => socket.close();
}Two things to notice:
attempts = 0resets when a new connection succeeds — so a brief flake doesn't poison future retries.Math.min(30000, ...)caps the delay. Without a cap, the wait would grow until it's effectively forever.
For most apps, this 12-line pattern is enough. Production-grade clients add buffered messages (queue while disconnected, flush on reconnect), heartbeat pings (ping/pong every 30 seconds to detect zombie connections), and authentication on reconnect — but the bones are this loop.
Don't reconnect inside the error handler. The close event always follows an error. If you reconnect on both, you'll open two sockets.
Try it yourself
type field. Why type and not just plain JSON?error listener, you call connectAgain(). Inside close you also call connectAgain(). What happens?