A WebSocket demo takes fifteen minutes. A WebSocket system takes considerably longer, because the demo skips everything that matters: what happens when the connection dies silently, how a second server instance learns about a message, who is actually online when three tabs are open, and what your process does when one client stops reading while you keep writing.
The difficulty is not the protocol. It is that you have introduced long-lived, stateful, in-memory connections into an architecture that was almost certainly designed around stateless requests. This covers the lifecycle, authentication, reconnection, presence, horizontal scaling, backpressure and failure handling - the parts that turn a demo into something you can operate.
What a WebSocket actually is
A WebSocket starts life as an HTTP request. The client sends an upgrade request, the server agrees with 101 Switching Protocols, and from that point the TCP connection carries WebSocket frames instead of HTTP messages. It is full-duplex - either side can send at any time - and it stays open until something closes it.
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: v1.chat
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: v1.chatSec-WebSocket-Protocol is a genuine subprotocol negotiation and almost nobody uses it. It is the cleanest place to put your message-format version - see the versioning note later.Three consequences worth internalising
- A WebSocket is one TCP connection and does not multiplex over HTTP/2. Ten thousand concurrent users means ten thousand sockets, with the file descriptors, memory and kernel buffers that implies.
- Connection state lives in one process. The socket is a file descriptor on one machine. Any other instance of your service cannot write to it, which is the root of every scaling problem in this article.
- The transport gives you no application semantics. No request/response correlation, no acknowledgement, no ordering guarantees beyond TCP's, no delivery guarantee across a reconnect. If you want those, you build them.
First, do you actually need one?
This section exists because the most common WebSocket mistake is using one. A persistent bidirectional connection is the right tool when the client sends frequently. When it mostly listens, you are paying for statefulness you do not use.
| Transport | Direction | Reconnect | Best for |
|---|---|---|---|
| Server-Sent Events | Server → client | Built in, with Last-Event-ID replay | Notifications, dashboards, log tails, LLM token streams |
| WebSocket | Bidirectional | You write it | Chat, multiplayer, collaborative editing, live cursors |
| Long polling | Server → client, effectively | Every cycle | A fallback when a proxy strips Upgrade. Nothing else |
| WebTransport | Bidirectional, plus datagrams | You write it | Latency-critical work - once Safari ships it |
SSE deserves more consideration than it usually gets. It is plain HTTP, so it passes through every proxy, works with normal auth, and reconnects automatically with replay from the last event ID - for free. Over HTTP/2 it multiplexes, so the old connection-limit objection no longer applies. It is why every major LLM provider streams tokens over SSE rather than WebSocket.
One more constraint worth checking early: serverless platforms handle WebSockets poorly, because a function that lives for seconds cannot hold a connection for hours. Vercel added native WebSocket support to its functions in public beta in June 2026, and the connection is pinned to a single function instance with a default cap of five minutes. That is fine for a session-scoped stream and not a substitute for a connection layer.
The connection lifecycle
States, and the one that lies to you
const ws = new WebSocket('wss://api.example.com/ws', ['v1.chat'])
ws.readyState
// 0 CONNECTING
// 1 OPEN ← does NOT mean the peer is reachable
// 2 CLOSING
// 3 CLOSED
ws.onopen = () => { /* handshake complete */ }
ws.onmessage = (e) => { /* a frame arrived */ }
ws.onerror = (e) => { /* deliberately uninformative, for security */ }
ws.onclose = (e) => { console.log(e.code, e.reason, e.wasClean) }onerror in the browser gives you almost nothing on purpose - exposing why a connection failed would leak network information to scripts. Diagnose from the close code instead.OPEN means the handshake completed, not that the other end is still there. A laptop closing its lid, a phone switching from Wi-Fi to cellular, or a NAT table entry expiring all produce a socket that reports OPEN on both sides while nothing can traverse it. This is the half-open connection, and it is the single most common source of "the app looks connected but nothing updates".
Heartbeats are mandatory
// Server: ping every client, drop anyone who missed the last pong
const HEARTBEAT_MS = 30_000
wss.on('connection', (ws) => {
ws.isAlive = true
ws.on('pong', () => { ws.isAlive = true })
})
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate() // not close() - the peer is gone, do not wait
continue
}
ws.isAlive = false
ws.ping()
}
}, HEARTBEAT_MS)
wss.on('close', () => clearInterval(interval))ping/pong are protocol-level control frames the browser answers automatically - no client code needed. terminate() destroys the socket immediately; close() waits for a handshake that will never arrive.Set the interval below every idle timeout in the path. Load balancers, reverse proxies and cloud gateways all cut idle connections - AWS ALB defaults to sixty seconds, and many others sit in the same range. A thirty-second heartbeat keeps the connection classified as active.
Close codes carry meaning
| Code | Meaning | Should the client retry? |
|---|---|---|
| 1000 | Normal closure | No - this was intentional |
| 1001 | Going away - server shutting down, or tab closing | Yes, after a delay |
| 1006 | Abnormal closure, no close frame received | Yes. This is what a dropped network looks like |
| 1011 | Server encountered an unexpected condition | Yes, with backoff |
| 1012 / 1013 | Service restarting / try again later | Yes, respecting any hint |
| 4000–4999 | Application-defined - yours to allocate | Depends on what you defined |
Authentication
This is where the browser API actively works against you: you cannot set custom headers on a browser WebSocket handshake. No Authorization: Bearer. The constructor takes a URL and an optional subprotocol array, and that is all.
The options, in order of preference
| Approach | How it works | Verdict |
|---|---|---|
| Ticket / one-time token | POST to an authenticated HTTP endpoint, receive a short-lived single-use ticket, connect with ?ticket=… | The right default. No long-lived credential in a URL |
| Cookie | The browser sends cookies on the handshake automatically | Works, but CSRF applies - you must validate Origin yourself |
| First-message auth | Connect unauthenticated, send a token as the first frame, authenticate then | Workable; requires an unauthenticated-connection state and a timeout |
| Token in the query string | wss://…/ws?token=eyJ… | Avoid. URLs land in access logs, proxy logs and error reports |
| Subprotocol smuggling | Pass the token in Sec-WebSocket-Protocol | A hack that works. Same leakage risk, less obvious |
// 1. Client asks an ordinary authenticated endpoint for a ticket
const { ticket } = await fetch('/api/ws-ticket', {
method: 'POST',
credentials: 'include',
}).then((r) => r.json())
// 2. Connect with it. Valid for ~30 seconds, single use.
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`)// Server: issue
app.post('/api/ws-ticket', requireAuth, async (c) => {
const ticket = crypto.randomUUID()
await redis.set(
`ws:ticket:${ticket}`,
JSON.stringify({ userId: c.var.user.id }),
'EX', 30,
)
return c.json({ ticket })
})
// Server: redeem, during the upgrade, before accepting the socket
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url!, 'http://localhost')
// CSRF: cookies are sent automatically, so the origin must be checked
if (!ALLOWED_ORIGINS.has(req.headers.origin ?? '')) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
return socket.destroy()
}
// GETDEL: redeem atomically so a captured ticket cannot be replayed
const raw = await redis.getdel(`ws:ticket:${url.searchParams.get('ticket')}`)
if (!raw) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
return socket.destroy()
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, JSON.parse(raw))
})
})Authorisation does not end at the handshake
Authenticating the connection tells you who the user is. It does not tell you whether they may subscribe to a particular room, or whether they still may an hour later. Every subscribe and every inbound message needs its own check against current permissions - a connection opened before a user was removed from a channel will happily keep receiving it unless you check.
// Connections outlive tokens. Handle expiry explicitly.
const EXPIRY_GRACE_MS = 60_000
function scheduleTokenExpiry(ws: Socket, expiresAt: number) {
const ms = expiresAt - Date.now() - EXPIRY_GRACE_MS
ws.expiryTimer = setTimeout(() => {
ws.send(JSON.stringify({ type: 'auth.refresh_required' }))
// Give the client a window to send a fresh token before closing
ws.graceTimer = setTimeout(
() => ws.close(4002, 'token expired'),
EXPIRY_GRACE_MS,
)
}, Math.max(0, ms))
}Reconnection
Every connection will drop. Phones change networks, laptops sleep, proxies time out, you deploy. Reconnection is not an edge case; it is the normal operating mode, and getting it wrong produces the two failure modes that hurt most: a thundering herd, and silently missing messages.
Backoff with jitter, and why the jitter matters
class ResilientSocket {
private attempt = 0
private ws?: WebSocket
private closedByUs = false
connect() {
this.ws = new WebSocket(this.url())
this.ws.onopen = () => {
this.attempt = 0 // reset only on a real open
this.resubscribe()
}
this.ws.onclose = (e) => {
if (this.closedByUs || e.code === 1000) return
if (e.code === 4001) return this.reauthenticate()
this.scheduleReconnect()
}
}
private scheduleReconnect() {
const base = Math.min(1000 * 2 ** this.attempt, 30_000)
// Full jitter: pick uniformly in [0, base), not base ± a bit
const delay = Math.random() * base
this.attempt++
setTimeout(() => this.connect(), delay)
}
}- Do not reconnect on close code 1000. That was intentional - usually yours.
- Distinguish auth failures from network failures. Retrying a rejected token forever is a loop that never resolves.
- Stop when the tab is hidden, and reconnect on
visibilitychange. A backgrounded tab reconnecting on a timer wastes battery and server slots. - Use
navigator.onLineand theonlineevent as a hint to retry immediately rather than waiting out the backoff. - Cap the attempt count and surface a real "reconnecting" state in the UI. A silent client that has given up is worse than an error message.
Resumption: the part people skip
Reconnecting restores the socket. It does not restore the messages sent while you were gone. Unless you design for it, every reconnect is a gap - and users experience gaps as the application being broken.
// Client stores the last message id it processed
const ws = new WebSocket(`${base}?ticket=${ticket}&since=${lastSeenId ?? ''}`)
// Server, on connect: replay from a bounded buffer before live delivery
async function onConnect(ws: Socket, rooms: string[], since?: string) {
if (since) {
for (const room of rooms) {
// A Redis stream per room gives you exactly this, with a cap
const missed = await redis.xrange(`room:${room}`, `(${since}`, '+', 'COUNT', 500)
for (const [id, fields] of missed) {
ws.send(JSON.stringify({ id, ...decode(fields) }))
}
}
}
subscribeLive(ws, rooms)
}XTRIM the stream to a bounded length. If a client has been gone longer than the buffer, tell it so explicitly and let it do a full refetch - a resync_required message is far better than a silent gap.Presence
Presence - who is online, who is in this room, who is typing - looks trivial and is the feature most likely to be subtly wrong in production. The naive implementation is a set you add to on connect and remove from on disconnect, and it fails immediately.
| Failure | Why the naive version breaks |
|---|---|
| A process crashes | No disconnect handler runs. Those users are online forever |
| Half-open connections | The socket never closes, so the user never leaves |
| Multiple tabs | One tab closes and the user disappears while still connected in two others |
| Multiple devices | Phone and laptop are two connections, one person |
| A network partition between nodes | Each node's view of presence diverges and neither knows |
Model presence as a lease, not a flag
The fix is to stop treating presence as durable state. It is a claim that expires unless renewed. Each connection registers itself with a TTL and refreshes on every heartbeat; anything that stops refreshing disappears on its own, whether it disconnected cleanly, crashed, or vanished.
// One key per connection, not per user. TTL slightly over 2 heartbeats.
const PRESENCE_TTL = 75
async function register(userId: string, connId: string, room: string) {
await redis.set(`presence:${room}:${userId}:${connId}`, nodeId, 'EX', PRESENCE_TTL)
}
// Called from the same heartbeat loop that pings the socket
async function refresh(userId: string, connId: string, room: string) {
await redis.expire(`presence:${room}:${userId}:${connId}`, PRESENCE_TTL)
}
// A user is present if ANY of their connections is
async function whoIsHere(room: string): Promise<string[]> {
const users = new Set<string>()
for await (const key of redis.scanIterator({ MATCH: `presence:${room}:*` })) {
users.add(key.split(':')[2])
}
return [...users]
}- Debounce join and leave events. A brief reconnect should not emit "left" then "joined" - hold leave notifications for a grace period longer than a typical reconnect.
- Presence is eventually consistent, and that is fine. Do not build features that require it to be exact. Nobody is harmed by a name lingering for thirty seconds; a system that stalls trying to be precise is worse.
- Typing indicators are ephemeral, not presence. Fire-and-forget with a short client-side timeout. They do not need durability, replay or a Redis key.
- Never derive authorisation from presence. Being listed in a room is a UI fact, not a permission.
- Use
SCAN, neverKEYS, for enumerating presence keys - the reasoning is the same as in any Redis deployment.
Horizontal scaling
Here is the central problem, stated plainly. Alice is connected to node A. Bob is connected to node B. Bob sends a message to their shared room. Node B holds Bob's socket and has no way to write to Alice's, because Alice's socket is a file descriptor on a different machine.
Alice Bob
│ │
┌────▼────┐ ┌────▼────┐
│ node A │ │ node B │
└────┬────┘ └────┬────┘
│ │
└──────► Redis ◄──────────┘
pub/sub
Node B publishes to `room:42`. Every node subscribed to that
channel receives it and writes to its own local sockets.Sticky sessions come first
Before any of that, the load balancer has to keep a client on the node it connected to. A WebSocket is a single long-lived TCP connection, so once established it stays put - but the *upgrade* request must reach a node that can serve it, and any reconnect must be able to land anywhere. Configure session affinity, and make sure your idle timeout exceeds your heartbeat interval.
Redis pub/sub as the message bus
import Redis from 'ioredis'
// Two connections: a subscriber cannot issue other commands
const sub = new Redis(url)
const pub = new Redis(url)
const localRooms = new Map<string, Set<Socket>>()
sub.on('messageBuffer', (channel, payload) => {
const room = channel.toString().slice('room:'.length)
const sockets = localRooms.get(room)
if (!sockets) return
const frame = payload.toString()
for (const ws of sockets) {
if (ws.readyState === WebSocket.OPEN) ws.send(frame)
}
})
export async function joinRoom(ws: Socket, room: string) {
let sockets = localRooms.get(room)
if (!sockets) {
sockets = new Set()
localRooms.set(room, sockets)
await sub.subscribe(`room:${room}`) // subscribe once per node, not per socket
}
sockets.add(ws)
}
export async function leaveRoom(ws: Socket, room: string) {
const sockets = localRooms.get(room)
if (!sockets) return
sockets.delete(ws)
if (sockets.size === 0) {
localRooms.delete(room)
await sub.unsubscribe(`room:${room}`) // stop paying for rooms with nobody here
}
}Where Redis pub/sub stops being enough
| Limitation | What it means | What to do instead |
|---|---|---|
| No durability | A node that is down when a message publishes never receives it | Redis Streams with consumer groups, or a real broker |
| No replay | Nothing to serve a reconnecting client from | Keep a bounded stream per room for the resume window |
| Fan-out cost | Every node receives every message for rooms it subscribes to | Shard rooms across nodes, or use a purpose-built layer |
| Cluster mode caveats | Pub/sub across a Redis Cluster needs care with slot routing | Use sharded pub/sub (SSUBSCRIBE) or keep the bus on one instance |
| Backpressure is invisible | Redis will happily deliver faster than a node can write to sockets | Handle it at the socket layer - the next section |
Redis Streams are usually the better default once you need resumption anyway. You get an ordered log per room with IDs you can hand to clients as resume tokens, bounded by XTRIM, and pub/sub-like fan-out through consumer groups. The same infrastructure then serves both live delivery and replay.
Backpressure
This is the failure mode that takes down the process, and it is invisible until it happens. ws.send() does not block. If a client stops reading - a phone on a train, a tab throttled in the background, a laptop asleep - your sends queue in the server's memory. Enough slow clients and the process runs out of heap.
const MAX_BUFFERED = 1024 * 1024 // 1MB of unsent data per socket
function safeSend(ws: Socket, frame: string): boolean {
if (ws.readyState !== WebSocket.OPEN) return false
if (ws.bufferedAmount > MAX_BUFFERED) {
// This client cannot keep up. Do not keep queueing for it.
metrics.increment('ws.slow_consumer_dropped')
ws.close(4008, 'too slow')
return false
}
ws.send(frame)
return true
}bufferedAmount is the number of bytes queued but not yet written to the network. It exists on both the browser and server APIs and almost nobody checks it.Strategies, and choosing between them
| Strategy | Behaviour | Right for |
|---|---|---|
| Disconnect the slow consumer | Close above a buffer threshold; the client reconnects and resumes | Most systems. Simple, bounded, and the resume path already exists |
| Drop intermediate messages | Keep only the newest state per key | Tickers, cursors, presence - where only the latest value matters |
| Coalesce and batch | Accumulate for 50ms, send one combined frame | High-frequency updates. Cuts frame overhead dramatically |
| Per-client queue with a cap | Explicit bounded queue, drop oldest on overflow | When you need control over which messages are lost |
| Unbounded buffering | Queue everything and hope | Never. This is how the process dies |
// Coalescing: one frame per tick instead of one per event
class Batcher {
private pending = new Map<string, unknown>() // keyed: newest wins
private timer?: NodeJS.Timeout
push(key: string, value: unknown) {
this.pending.set(key, value)
this.timer ??= setTimeout(() => this.flush(), 50)
}
private flush() {
this.timer = undefined
if (this.pending.size === 0) return
const batch = [...this.pending.entries()]
this.pending.clear()
safeSend(this.ws, JSON.stringify({ type: 'batch', updates: batch }))
}
}Apply the same discipline inbound. A client can send as fast as it likes, so rate limit per connection, cap message size at the server (maxPayload in ws), and drop connections that exceed either. An unbounded inbound path is a denial of service you built yourself.
Failure handling and operations
Deploys are a mass disconnection event
Every rolling restart disconnects every connected client. With a naive client, they all reconnect within a second or two of each other, arrive together at whichever nodes are up, and can take those down as well.
// Graceful shutdown: stagger the disconnect, tell clients why
async function shutdown() {
server.close() // stop accepting new upgrades
const clients = [...wss.clients]
const windowMs = 20_000
const step = windowMs / Math.max(clients.length, 1)
clients.forEach((ws, i) => {
setTimeout(() => {
// 1012 = service restarting. Clients can reconnect promptly.
ws.close(1012, 'restarting')
}, i * step)
})
await sleep(windowMs + 5_000)
process.exit(0)
}
process.on('SIGTERM', shutdown)SIGKILL you mid-drain.What to monitor
- Concurrent connections per node, and the spread across nodes. A skewed distribution means affinity or reconnect behaviour is wrong.
- Connection duration histogram. A cluster of very short connections means clients are failing and retrying - often an auth problem you cannot see any other way.
- Close codes, broken out. A rise in 1006 is a network or proxy problem; a rise in your 4001 is an auth problem.
bufferedAmountpercentiles and slow-consumer drops. These are your early warning for memory pressure.- Reconnect rate. Spikes align with deploys, proxy restarts and mobile network events.
- Messages published versus messages delivered. A widening gap means the fan-out layer is losing traffic.
- File descriptor usage against the process limit. Sockets are file descriptors, and the default
ulimitis often far too low.
Degrade rather than fail
- If the socket cannot connect, fall back to polling. A slower app beats a broken one, and some corporate proxies genuinely strip
Upgrade. - Show connection state in the UI. A quiet "reconnecting" indicator prevents the worst outcome, which is a user trusting stale data.
- Never make a socket a hard dependency of a page load. Render from HTTP-fetched state, then let the socket deliver updates.
- Make writes go over HTTP where they matter. A
POSTyou can retry with an idempotency key is more reliable than a frame with no acknowledgement. - Version your message format - in the subprotocol, or a
vfield on every message. Clients update on their own schedule, and old ones will still be connected during your deploy.
Mistakes that keep recurring
| Mistake | Consequence |
|---|---|
| No heartbeat | Half-open connections accumulate; users see a connected app that never updates |
No Origin check with cookie auth | Cross-site WebSocket hijacking - any page can open an authenticated socket |
| Reconnecting without jitter | A restart becomes a thundering herd that prevents recovery |
No bufferedAmount check | One slow client's queue grows until the process runs out of memory |
| Presence stored as a durable flag | Ghost users after every crash, and one closed tab removing an active user |
| No message resumption | Silent gaps after every reconnect; users see stale data and do not know |
| In-memory room state on multiple nodes | Half your users never receive the message |
| Long-lived token in the connection URL | Credentials in access logs, proxy logs and error reports |
| Idle timeout shorter than the heartbeat | The load balancer cuts healthy connections on a timer |
| Rolling restart with no stagger | Every client reconnects in the same second |
| Using WebSockets for a one-way feed | All the statefulness, none of the benefit - SSE would have done it |
| No inbound rate limit or payload cap | A denial of service any client can trigger |
Verdict
WebSockets are the right tool for genuinely bidirectional, high-frequency communication - chat, multiplayer, collaborative editing, live cursors. Nothing else gives you that with comparable latency and overhead. For anything where the client mostly listens, SSE does the job with automatic reconnection, standard authentication and stateless servers, and you should take that deal.
When you do need them, the work is not in the protocol. It is in accepting that you have introduced long-lived state into a stateless architecture and handling the consequences deliberately: heartbeats because connections lie, jittered backoff because clients reconnect together, leases because presence cannot be durable, a message bus because sockets live on one node, and buffer limits because slow clients are the ones that take down the process.
Assume every connection is already broken and you just have not noticed yet. Design the reconnect and resume path first, and the rest of the system follows from it.
If you build three things properly - heartbeats with terminate(), jittered reconnection with a resume token, and a bufferedAmount check on every send - you will have avoided the majority of production WebSocket incidents. The rest is operational care: staggered deploys, close codes that mean something, and monitoring that shows you connection churn before your users report it.
Sources
- RFC 6455 - the WebSocket protocol, including the close code registry
- MDN WebSocket API - the browser interface and its constraints
- ws - the Node.js implementation used in the examples, including
maxPayloadandterminate - MDN Server-Sent Events - the simpler option, with built-in reconnection
- Redis Streams - ordered logs with consumer groups, for replay and fan-out
- WebTransport - the HTTP/3 successor, and its current browser support



