Area: Real-time networking
Related: Browser APIs (map), Server-Sent Events, Page Lifecycle, Web Locks API, Yjs
What is it?
A WebSocket is a long-lived, two-way connection between the browser and a server. Once it’s open, either side can send a message at any time, with no new HTTP request each time.
HTTP is a phone call where you must hang up after every question. A WebSocket is a call you keep open, and both people can talk whenever they want.
It’s what powers chat, live cursors, collaborative editors, multiplayer games and live dashboards.
How the connection starts
A WebSocket begins life as a normal HTTP request that asks to upgrade:
GET /collab HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server agrees:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After the 101, the same TCP connection stops speaking HTTP and starts speaking the WebSocket protocol: small frames in both directions.
The Sec-WebSocket-Key / Accept pair isn’t security. It just proves the server really understands WebSockets and isn’t some HTTP server that echoed headers back by accident.
ws:// is plain, wss:// is over TLS. Always use wss:// in production. Besides security, plenty of corporate proxies break unencrypted WebSockets, because they try to read the traffic as HTTP.
The browser API
const ws = new WebSocket("wss://example.com/collab?doc=n1");
ws.binaryType = "arraybuffer"; // default is "blob"
ws.onopen = () => ws.send(JSON.stringify({ type: "hello" }));
ws.onmessage = (event) => {
if (typeof event.data === "string") handleText(event.data);
else handleBinary(new Uint8Array(event.data));
};
ws.onerror = () => {}; // fires before close; carries almost no detail
ws.onclose = (event) => console.log("closed", event.code, event.reason, event.wasClean);
ws.close(1000, "done");readyState: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED. Calling send while CONNECTING throws.
Messages are text (strings) or binary (ArrayBuffer or Blob). Binary is what you want for compact protocols. Collaborative editors send CRDT updates as raw bytes.
Close codes
| Code | Meaning |
|---|---|
1000 | Normal close |
1001 | Going away (page closing, server restarting) |
1006 | Abnormal. The connection dropped with no close frame. You never send this. You only receive it. It means “the network died” or “the server crashed”. |
1008 | Policy violation (often used for auth failures) |
1009 | Message too big |
1011 | Server error |
4000 to 4999 | Free for your app to define, like 4401 unauthorized |
onerror tells you almost nothing on purpose (to avoid leaking network details to scripts). The close code and reason are where the information is.
Authentication
The browser WebSocket constructor can’t set custom headers. No Authorization: Bearer .... The options are:
- Cookies. Sent automatically with the upgrade request if they’re for the same site. Simple, but then you need to protect against cross-site WebSocket hijacking by checking the
Originheader on the server. - Token in the URL (
?token=...). Works, but URLs end up in logs. Use short-lived tokens. - Token in the first message. Open the socket, send
{ type: "auth", token }first, and the server ignores everything until auth succeeds. This is the most common pattern in real-time libraries. - Subprotocol hack: pass the token as a
Sec-WebSocket-Protocolvalue. Some people do this. It’s a hack.
Tokens expire. A connection that lives for hours will outlive its token. Either the server re-checks periodically and asks the client for a fresh token, or the client sends a new one over the socket before the old one expires.
Always check the Origin header on the server. Browsers don’t apply CORS to WebSockets. Any website can open a WebSocket to your server, and the user’s cookies will be sent along.
Keeping it alive
Connections die quietly. Wi-Fi changes, a laptop sleeps, a phone switches to 4G, a proxy decides the connection is idle. Often neither side gets a close event for a long time. TCP alone can take many minutes to notice a dead peer.
The protocol has ping and pong frames, but browsers don’t let JavaScript send pings. The server can send pings and the browser answers them automatically. Many libraries also add their own app-level heartbeat:
- Send a small message every N seconds.
- If nothing has arrived for M seconds (M > N), assume the connection is dead, close it and reconnect.
Load balancers and reverse proxies often cut connections that have been silent for a while. Your heartbeat interval has to be shorter than the smallest idle timeout anywhere in the path, including your own client library’s “no message received” timeout.
Reconnecting properly
A real app reconnects on its own. The rules:
- Back off exponentially with jitter. Wait 1 s, 2 s, 4 s, 8 s… up to a cap, and add randomness. Without jitter, when your server restarts, every client reconnects at the exact same moment and knocks it over again (the “thundering herd”).
- Reconnect sooner when it obviously helps: on the
onlineevent, and when the tab becomes visible again (see Page Lifecycle). - Resync after reconnecting. Messages sent while you were offline are gone. The client has to tell the server what it already has and get the difference. CRDT protocols do exactly this: exchange “state vectors” (what I’ve seen so far) and send each other only the missing updates.
- Queue outgoing messages while disconnected and flush them after resync, or design the protocol so the resync covers them.
- Show the state to the user (“Reconnecting…”) when it lasts more than a few seconds.
Backpressure
ws.send() never blocks. If you send faster than the network can carry, data piles up in the browser’s buffer. ws.bufferedAmount tells you how many bytes are still waiting. For big transfers, check it and slow down.
There’s a newer WebSocketStream API (Chrome) that uses streams and handles backpressure properly, but it isn’t everywhere yet.
Scaling: the stateful server problem
HTTP servers are easy to scale because each request is independent. A WebSocket server holds state per connection, and usually per room or document as well.
- With several server instances, all clients in the same room usually need to land on the same instance, or the instances need to share state through something like Redis pub/sub.
- Load balancers need sticky sessions (or routing by room) so a reconnect goes back to the same instance.
- Deploys drop every connection at once. Expect a reconnect wave, and make sure backoff with jitter handles it.
WebSocket vs the alternatives
| Direction | Built on | Good for | |
|---|---|---|---|
| WebSocket | both ways | upgraded TCP connection | chat, collaboration, games |
| Server-Sent Events | server → client only | plain HTTP response that stays open | notifications, AI token streaming, live feeds |
| Long polling | fake two-way | repeated HTTP requests | old fallback |
| WebTransport | both ways, many streams | HTTP/3 (QUIC) | newer, lower latency, unreliable datagrams; not universal yet |
If the server only pushes and the client rarely talks, SSE is simpler. If both sides talk constantly, use a WebSocket.
A real-world example
A collaborative editor runs one WebSocket per open note to a Yjs sync server. Yjs updates travel as binary messages.
- Keepalive. The client library closes a socket that’s been silent for 30 s, so quiet documents kept redialling every 30 to 60 s. The fix was a tiny, data-free keepalive from the server every 20 s, shorter than the smallest client timeout.
- Auth. Before the server saves with a token that’s about to expire, it asks connected clients for a fresh one with a small “refresh your token” message over the socket.
- Stateful by design. Each document’s state lives in the server’s memory, so the sync server runs as exactly one instance with sticky WebSockets. A second instance behind a load balancer would corrupt its bookkeeping. Scaling means adding more pinned instances and routing each document to one of them.
- Resync. After a reconnect, Yjs state vectors let the client and server exchange only the updates each is missing.
Gotchas
- No custom headers in the browser constructor. Plan auth before you build.
- No CORS. Check
Originon the server. onerrorhas no useful detail. Read the close code.- Code
1006means “died without saying goodbye”. Reconnect. - Silent connections die. Use a heartbeat shorter than every idle timeout in the path.
- Always back off with jitter.
- After reconnecting, resync. Never assume you didn’t miss anything.
- Use
wss://everywhere. - Multiple server instances need sticky routing or shared state.
Practice
- Write a tiny Node server with the
wspackage that echoes messages. Watch the upgrade request and frames in the DevTools Network tab (click the request, then Messages). - Add a heartbeat. Pull your network cable (or toggle Wi-Fi) and see how long it takes to notice the drop with and without it.
- Add reconnect with exponential backoff and jitter. Restart the server while ten tabs are open and watch the reconnect times spread out.
- Make a counter that two tabs edit through the server. Disconnect one, edit in both, reconnect, and figure out how to resync.
Sources worth reading
- MDN: WebSocket, Writing WebSocket client applications, CloseEvent codes
- RFC 6455: The WebSocket Protocol (sections 1 and 4 are readable)
- Hocuspocus and y-websocket docs, for a real sync protocol on top of WebSockets