WebSockets in the UI
Bidirectional, framed, and no longer HTTP — which means heartbeats, reconnection, backoff, auth on connect, message schema and versioning are now yours.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What exactly did I take on when I replaced request/response with an open connection?
Two people are editing the same document, or a trader is watching a book move, or a room of people can see each other's cursors. Both directions matter and the delay is felt.
Open a WebSocket, JSON.parse the messages, update state. It is a socket — send and receive. The hard part was the server; the client is a few event handlers.
The first laptop lid closes. The socket is gone, onclose fires, nothing reconnects, and the user stares at a page that looks live and is not (Reconnect and Backoff).
- The first laptop lid closes. The socket is gone,
onclosefires, nothing reconnects, and the user stares at a page that looks live and is not (Reconnect and Backoff). - The first NAT timeout. Nothing fires at all: no close, no error, no data. The connection is dead and both ends believe it is fine, which is why heartbeats exist.
- The first deploy. Every client drops at the same instant and reconnects at the same instant, and the service that just started serving falls over under the synchronised load (Retry Storms: The Load You Generated Yourself in Observability).
- The first token expiry. Auth was checked at connect; four hours later the connection is still privileged, still streaming, and still trusted (Session Expiry and the Refresh Race).
- The first schema change. Old tabs are still connected, speaking last week's message format to this week's server, and neither side declared a version (Long-Lived Clients and Version Skew).
- The first burst. Two thousand messages arrive in a second, each triggering a state commit, and the main thread stops answering the keyboard (Long Tasks).
What is actually happening
In the browser, not in the framework.
- The connection begins as an HTTP GET with
Upgrade: websocketand a key header. A101 Switching Protocolsresponse means the same TCP connection stops speaking HTTP and starts carrying WebSocket frames (WebSockets in Networking). - After the upgrade there is no request/response pairing. A message you send and a message you receive are unrelated events; correlating them is your job, usually with an id in the payload (WebSocket Message Contracts in API Design).
- Frames carry text or binary. The browser reassembles fragmented frames and hands you a
MessageEvent, and it handles ping and pong frames itself — but the browser gives you no way to send an application-level ping, which is why heartbeats are usually messages in your own schema. - Ordering is guaranteed within one connection, because it is one TCP stream. Across a reconnect there is no ordering relationship at all, and there is no delivery guarantee for anything sent while the connection was down (Ordering and Duplicate Delivery).
- The close event carries a code and a reason. Distinguishing "the server rejected your auth" from "the network went away" is the difference between retrying forever and sending the user to sign in again (Login Redirects and the Open-Redirect Trap).
- The handshake is an HTTP request that carries cookies and is not restricted by the same-origin rules that govern
fetch, so the server must validate theOriginheader itself (The Same-Origin Policy).
What this makes the browser do
And which of it is avoidable.
- One connection per socket, held for the session. Unlike SSE this is not counted against the HTTP/1.1 per-origin budget, but it is still a connection through every intermediary in the path.
- Each inbound message is a task: parse, dispatch, reduce, render. Message rate — not payload size — is what turns a socket into a main-thread problem (Tasks: The Unit That Cannot Be Interrupted).
- The browser buffers outbound data when the connection cannot drain;
bufferedAmountis the only backpressure signal you get, and ignoring it turns a slow network into unbounded memory growth (Memory Leaks). - Avoidable work:
JSON.parseon the main thread for high-rate binary-ish payloads, one state commit per message rather than per frame, and re-rendering a list on every tick when only two rows changed (Reconciliation and Keys).
One HTTP request, and then it is not HTTP any more
The handshake is worth drawing because it explains both the infrastructure friction and the security surface. It is an ordinary GET — so it carries cookies, passes through the same proxies, and is subject to connect-src — right up until the 101, after which every intermediary in the path is forwarding an opaque byte stream it cannot inspect, cache or route by URL.
That single transition is the source of most of this lesson. Everything HTTP did per request — authenticate, authorize, log, cache, version, rate-limit — happened once, at the arrow labelled 101, and never again (The Lifecycle of One HTTP Request in Networking).
- Before the
101: cookies, headers, CSP, proxies, logs, rate limits — the whole HTTP toolkit. - After the
101: your schema, your correlation ids, your heartbeat, your version field. Nothing in the path understands any of it. - The
Originheader on the handshake is the only thing standing between your socket and a page you have never heard of (Cross-Site Request Forgery).
What you took on
This table is the lesson. Each row is something request/response was doing quietly on your behalf and that a socket now expects you to implement — usually as an afterthought, usually after the incident.
None of the right-hand column is difficult in isolation. The difficulty is that all eight arrive together, that each one is only exercised by conditions your test environment does not reproduce, and that the failure of any one of them looks like "the app is stale" (Network Failures Only the Client Can See).
| What HTTP did for you | What the socket makes you own | How the gap shows up in production |
|---|---|---|
| A request that ends, so failure is observable | Liveness detection — an application-level heartbeat | A half-open connection: no error, no data, a confidently stale screen |
| Retry per request, scoped and bounded | Reconnection for the whole session | Every client retries at once after a deploy and knocks the service back over (Reconnect and Backoff) |
| Auth checked on every single request | Auth checked once, at connect | A session that stays privileged for hours after the credential expired (Session Expiry and the Refresh Race) |
| Status codes and a shared error model | A message schema with your own error convention | One malformed message throws in the handler and kills every message after it |
| Versioning by URL, header or content type | A version field, or a negotiation at connect | Tabs open across a deploy speak last week's dialect to this week's server |
| Backpressure via the request/response cycle | Your own outbound queue and drop policy | bufferedAmount grows unboundedly on a slow uplink; memory climbs and nothing looks wrong |
| Observability per URL, for free | One connection, thousands of messages, one row | Nobody can answer "how many messages does an idle dashboard receive" without new instrumentation |
| Ordering and delivery scoped to one request | Ordering within a connection only, at-least-once across reconnects | Duplicated and out-of-order events applied blindly, and state that drifts (Ordering and Duplicate Delivery) |
Auth on connect, and again on every reconnect
Origin header, and a subprotocol-smuggled token works but abuses a field meant for protocol negotiation.new WebSocket(url, protocols) accepts a URL and an optional subprotocol list. There is no header argument, which rules out the Authorization header the rest of your application uses. That constraint drives the design: either the connection authenticates with cookies, or it authenticates with something embedded in the URL.
The workable pattern is a short-lived, single-use ticket fetched over ordinary HTTP — where all the usual auth machinery still applies — and consumed by the handshake. The part everybody forgets is the second call: a reconnect is a new handshake and needs a fresh ticket, because the old one has been consumed or has expired during exactly the outage that caused the reconnect (Reconnect and Backoff).
1async function connect(): Promise<WebSocket> {2 // Ordinary HTTP: cookies, CSRF token, rate limits, logging all apply here.3 const res = await fetch('/api/realtime/ticket', { method: 'POST' })4 if (res.status === 401) { goToLogin(); throw new Error('unauthenticated') }5 const { ticket } = await res.json() // single use, short lifetime6 7 // No header argument exists. The ticket travels in the URL, so it must be8 // worthless once used and worthless soon regardless.9 const ws = new WebSocket(`${WS_ORIGIN}/stream?ticket=${encodeURIComponent(ticket)}`)10 11 ws.addEventListener('open', () => {12 // Reconnect is not resume: the server has no memory of what we watched.13 ws.send(JSON.stringify({ v: 2, type: 'subscribe', topics: currentTopics(), since: lastEventId }))14 })15 16 ws.addEventListener('close', (e) => {17 // 1008 / 4401-style application codes mean "do not retry, re-authenticate".18 if (e.code === 1008 || e.code === 4401) { goToLogin(); return }19 scheduleReconnect() // everything else is a network problem: back off, with jitter20 })21 22 return ws23}Three things carry the lesson: the ticket is fetched per attempt rather than reused, subscribe is re-sent on every open because the server remembers nothing, and the close code decides between "retry" and "send the user to sign in" — conflating those two produces either an infinite retry loop against a 401 or a login redirect every time a train enters a tunnel.
How to build it
Most important first.
- Own the connection in one module with an explicit state machine —
connecting,open,reconnecting,closed-permanently— and expose subscription, not the socket. Components that hold a socket reference will use it at the wrong moment (Who Owns This State?). - Write the heartbeat before the features. Send an application-level ping on an interval, expect a pong, and treat a missed pong as a dead connection to be closed and reopened; otherwise dead sockets are indistinguishable from quiet ones.
- Version every message. A
vfield or a version negotiated at connect is what lets you deploy a server while last week's tabs are still connected (Running Two API Versions in One Service in Backend Engineering). - Fetch a short-lived connection ticket over ordinary HTTP, connect with it, and fetch a fresh one on every reconnect.
new WebSocket()cannot send headers, so the credential has to be designed for the shape the API actually has (What the Frontend Is Responsible For in Auth). - Buffer inbound messages and commit once per animation frame. One commit per message is the most common performance defect in a socket-backed UI and the easiest to fix (The Rendering Opportunity).
- Decide the drop policy for outbound messages while disconnected: queue with a bound, drop, or refuse the interaction. An unbounded outbound queue is a memory leak that fires exactly when the network is worst (The Offline Mutation Queue).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A socket-backed UI updates without user action, so the announcement policy is part of the feature and not a decoration. Decide per message type whether it is announced, batched into a summary, or silent (Live Regions and Announcement).
- Connection state must be perceivable without colour. A green dot is not a status for a colour-blind user and not a status at all for a screen-reader user; use text in a polite status region (Contrast, Colour and Motion).
- Live cursors, presence avatars and typing indicators are pure decoration to assistive technology and pure noise if announced. Mark them
aria-hiddenand provide the same information in a form that can be read on demand (Semantics Before ARIA). - Content that arrives and reorders under a keyboard user moves their target between the decision to press and the press. Append rather than prepend, or hold new items behind an explicit control (Keyboard Operability).
- When a socket drops, say so in text. A screen-reader user has no visual cue that the numbers stopped moving, and silence reads identically to stability.
What can go wrong
- The half-open connection: the path is gone, no event fires, and the UI is confidently wrong until a heartbeat notices.
- Reconnect without re-auth: the new connection uses a stale token, the server accepts it because it only checks at connect, and an expired session lives on for hours (Session Expiry and the Refresh Race).
- Reconnect without resubscribe: the socket is back but the server does not know which entities this client cares about, so the connection is healthy and delivers nothing (Resynchronisation After a Gap).
- Message schema drift: a client that has been open across two deploys receives a field it cannot parse and throws inside the message handler, killing every subsequent message on that connection.
- The mitigation failing: a heartbeat with a timeout shorter than the real network's worst pause, which tears down healthy connections and produces a reconnect loop on exactly the networks that needed the heartbeat.
- Unbounded
bufferedAmountgrowth on a slow uplink — the tab's memory climbs while the user sees nothing wrong (Slow Clients and Backpressure in API Design).
- A send racing a close:
send()on a socket that is closing throws or silently discards, and the user's last action is lost with no error anywhere (Optimistic UI). - Reconnect racing resubscribe: messages can arrive on the new connection before the subscribe message has been processed, so the first events after reconnect may concern entities you no longer track.
- Two connections briefly alive at once — a reconnect that succeeds while the old socket has not finished closing — delivering every event twice for the overlap (Ordering and Duplicate Delivery).
- An optimistic local mutation racing the server's echo of the same change, arriving as an event that looks like someone else's edit (Rollback and Reconciliation).
- The handshake is not governed by the same-origin restrictions that apply to
fetch, and there is no preflight. The browser will happily open a cross-origin socket with the page's cookies attached, so the server must check theOriginheader on the handshake — that check is the whole defence (Cross-Site Request Forgery). - A cookie-authenticated socket opened from an attacker's page is the WebSocket form of cross-site request forgery: the browser attaches credentials automatically and the connection is then a two-way channel (Cross-Site Request Forgery in Security has the general mechanism).
- Authorization is checked once, at connect, unless you make it otherwise. Anything long-lived needs either re-authentication on a schedule or server-side revocation that closes live connections (Authorization-Aware UI).
- A token in the URL query string of a socket is logged by proxies and servers. A single-use ticket exchanged over HTTPS and consumed at connect keeps the credential out of logs (Cookies vs Script-Readable Tokens).
- Every inbound message is untrusted. Validate the shape before it reaches state, and never render a pushed string as HTML — the pushed path is the one that gets reviewed least (Cross-Site Scripting).
connect-srcin a Content-Security-Policy coverswss:endpoints and constrains where an injected script can open a channel to (Content Security Policy).
- "A WebSocket is a faster HTTP." It removes per-message request overhead and adds a permanent connection. Whether that is faster depends entirely on message rate and direction (Latency Is a Distribution, Not a Number in Observability).
- "
onclosemeans the user disconnected." It means this connection ended. The reason code says why, and most of the reasons have nothing to do with the user. - "The socket is authenticated, so the messages are authorized." The connection was authorized once. Every message still needs a server-side permission decision (Where Authorization Must Live in Security).
- "Ordering is guaranteed." Within one connection, yes. Across a reconnect there is no relationship, and that reconnect happens more often than any local test suggests (Ordering and Duplicate Delivery).
- "CORS applies to WebSocket handshakes." It does not, and assuming it does is how servers end up with no origin check at all (The Same-Origin Policy).
Measuring it, and what changes in the field
- The Network panel shows the
101and a frame list, with direction, opcode, size and time per frame — the only place the message rate is visible without your own instrumentation (Debugging the Network). - Instrument reconnect count, close codes and time-to-first-message per session. Close codes are the single most useful production signal this transport produces (Frontend Error Tracking).
- Watch
bufferedAmountin the field, not locally. It is the backpressure signal, and it only moves on networks you do not have. - Attribute main-thread time to message handling in the Performance panel; the socket is rarely the cost, the render per message usually is (Interaction Responsiveness).
- On a mobile network, disconnects are the normal case rather than the exception: radio state changes and NAT timeouts close connections silently, which makes heartbeat tuning the difference between a working feature and a broken one.
- Behind a corporate proxy the upgrade may be refused outright, so a fallback path — SSE or polling — is a real requirement for enterprise customers rather than defensive over-engineering (Choosing a Real-Time Transport).
- On a slow device, a burst of messages is a rendering problem long before it is a network problem; the same stream that is invisible on a laptop drops frames on a mid-range phone (The Frame Budget).
- In a long-lived tab, the client may be several deploys behind the server. Version negotiation at connect is what keeps that from being a support ticket (Deploying a Frontend).
- You gain a low-latency bidirectional channel and lose caching, conditional requests, per-request authorization, per-URL observability and the ability to debug with the tools everyone already knows.
- A connection state machine, a heartbeat, a backoff policy and a message schema are perhaps a few hundred lines — and they are load-bearing lines that need testing against conditions your test environment does not produce.
- Server-side, connections are state. Deploys, autoscaling and load balancing all become harder in ways that are invisible from the frontend but are caused by the frontend's choice (Sticky Sessions in Backend Engineering).
- Falling back to SSE or polling when the upgrade fails doubles the number of code paths that must stay correct, including the resynchronisation path in each.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe
WebSocketAPI surface, the upgrade handshake, frame semantics and close codes are specified and consistent across Blink, Gecko and WebKit; browsers differ only in devtools presentation and in how aggressively they close sockets in backgrounded or discarded tabs. - NETWORK-SPECIFICWhether the upgrade survives at all depends on the path: consumer networks generally pass it, while some corporate proxies and older load balancers refuse or silently drop it, which is why a fallback transport is an availability requirement rather than a nicety.
- PLATFORM-SPECIFICMobile platforms suspend backgrounded tabs and tear down radios, so the disconnect rate on a phone is orders of magnitude higher than on a desktop on wired ethernet, and heartbeat intervals tuned on one are wrong on the other.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — which node holds a given user's connection, how a subscription is routed to it, and what a rolling deploy does to a hundred thousand connections that must all land somewhere else.