In this guide
An HTTP request is a transaction. It arrives, it is served, it is forgotten, and any box in the middle can be replaced while nobody is looking.
A WebSocket is not a transaction. It is a connection you now own for as long as it lives, through deploys, through load balancer timeouts, through the four minutes a phone spends in a lift. Almost everything that is hard about WebSockets in production comes from that one difference, and almost none of it shows up on your laptop.
1. First, decide whether you need one
The useful question is not "is this real time?". Nearly everything is real time now. The question is what does the client send?
| The client sends… | Use | Why |
|---|---|---|
| Nothing. It only receives. | Server Sent Events | Plain HTTP. Reconnects on its own, passes through every proxy, multiplexes over HTTP/2, and needs no new infrastructure. |
| An occasional action | HTTP + SSE | Post the action, stream the result. Two well understood mechanisms beat one stateful one. |
| A steady stream, both ways | WebSocket | Full duplex over one TCP connection, low per message overhead. |
| Nothing, and it can wait | Polling | Genuinely fine at low frequency. Undignified, unbeatable for reliability. |
Live dashboards, notification feeds, deployment logs, token by token AI output: all SSE. Chat, multiplayer state, collaborative editing, remote terminals, live cursors: WebSocket, and take the operational bill with it.
The mistake is not choosing wrong. It is not noticing there was a choice.
2. The four things that break first
Idle timeouts, which are 60 seconds nearly everywhere
Nginx proxy_read_timeout, AWS ALB idle_timeout, HAProxy: all default to
60 seconds. If nothing crosses the proxy in that window, the proxy closes
the connection, and because the close happens in the middle, neither end
reliably finds out. The client sits holding a socket that is already dead and
the server keeps a session for a client that is gone.
This is the single most common WebSocket bug in production, and it never once reproduces locally, because there is no proxy on localhost.
The fix has two halves and you need both:
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Longer than the heartbeat, not longer than forever.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
And a heartbeat under the lowest timeout in the stack, proxy, load balancer, CDN, corporate middlebox, whatever is between you and the browser. 25 seconds clears every common default. On AWS, raise the ALB idle timeout to 300s so it sits comfortably above the server ping interval instead of racing it.
Send a real ping frame, not an application message. Ping/pong is in the protocol, costs a couple of bytes, and does not end up in your message handler.
Authentication, which cannot use a header
The browser WebSocket constructor takes a URL and an optional subprotocol
array. That is the whole API. You cannot set an Authorization header, so
the three options are:
- Token in the query string. Works everywhere. Also writes the token into the access log, yours, the load balancer's, the CDN's, and into any error tracker that captures URLs. If you do this, use a single use ticket with a 30 second expiry, not the session token.
- Cookie. Sent automatically on the upgrade request, so it authenticates
before the socket opens and you get a real
401. Now you own the CSWSH problem: browsers do not enforce same origin on WebSockets, so check theOriginheader server side yourself. Nothing else will. - First message. The socket opens unauthenticated and the client sends credentials as frame one. Most secure of the three and the easiest to reason about, but authentication now happens after the handshake, so failures are close codes rather than HTTP statuses and your monitoring has to be taught about them.
Pick the ticket or the first message pattern. Whichever you pick, put a deadline on it: a socket that has been open for five seconds without authenticating is closed.
Backpressure, which is how the memory goes
socket.send() does not block and does not fail when the client is slow. It
queues. A client on hotel wifi, or a browser tab that has been backgrounded and
throttled, accepts bytes far slower than a busy server produces them, and the
difference accumulates in your process.
The signal is bufferedAmount, bytes handed to send() that have not reached
the network:
const HIGH_WATER = 1_000_000; // ~1 MB queued for one client is already wrong
function push(socket, payload) {
if (socket.bufferedAmount > HIGH_WATER) {
// Choose: drop, coalesce, or disconnect. Never "queue anyway".
return false;
}
socket.send(payload);
return true;
}
What you do at the high water mark is a product decision, not a technical one. For a cursor position or a price tick, drop it, the next one is along in 16ms and is strictly better. For a chat message, disconnect and let the client reconnect and resync, because a dropped message is worse than a visible reconnect. The wrong answer is to keep queueing, which converts one slow client into an out of memory kill for everybody.
Deploys, because every one is a reconnect storm
Restart a server holding 40,000 connections and you get 40,000 clients reconnecting at once, most of them within the same second, most of them immediately re fetching whatever state they had. That is a self inflicted DDoS, and it fires on every deploy.
Two things fix it. Jittered exponential backoff on the client, random delay, not a fixed one, or you have just synchronised the herd instead of spreading it. And a resumable session: give each connection a session id and a cursor, so reconnecting resumes from the last message seen rather than replaying a full state load. Socket.IO ships this as connection state recovery; if you are hand rolling, build it before you need it, because you will need it during an incident.
3. Scaling past one machine
One server goes further than people expect. Each connection costs roughly 2 to 10 KB of memory plus one file descriptor, so 500,000 idle connections on a 16 GB box is achievable with tuned limits. Idle is doing a lot of work in that sentence: it is message throughput and fan out that actually costs you, not the sockets sitting there.
Raise the file descriptor limit before anything else. The default 1024 is the
first wall you hit and the error it produces, EMFILE, too many open files, looks nothing like "you have too many users".
Then the real problem arrives: rooms are local. Two users in the same chat on two different servers cannot see each other, because the server holding one has never heard of the other.
- Sticky sessions pin a client to a server so its state stays put. They work, they are the fastest thing to reach for, and they are a liability: an instance that dies takes its clients' state with it, and load spreads by luck rather than by need.
- A pub/sub backplane is the real answer. Every server subscribes to Redis, Kafka or NATS; a message published anywhere fans out to every server, and each delivers to its own local sockets. This is what the Socket.IO Redis adapter does, and it is why it exists.
- External state is what lets you drop the stickiness. Put session data in Redis rather than in process memory and any server can serve any reconnect, which is exactly what you want during a deploy or a scale in.
Redis itself becomes the ceiling somewhere around 100,000 messages per second, at which point you shard the pub/sub channels or move to Redis Cluster. Long before that, watch the fan out ratio rather than the message rate: one message into a room of 10,000 is 10,000 sends, and it is the sends that melt.
Scale out at 70% of measured capacity, and note measured. Load test the protocol with k6 or Artillery, with a realistic message mix, 50,000 idle sockets and 50,000 chatty ones are not the same workload and only one of them is easy.
4. Where it runs
This is where a lot of otherwise sensible architectures fall over, because the platform that hosts the app often cannot hold the connection.
Vercel shipped native WebSocket support in public beta in June 2026, which changed the old blanket answer of "you can't". It did not change the underlying shape: the connection lives inside a function invocation and inherits its duration limit, 300 seconds at most, and a reconnect is not guaranteed to land on the same instance. That is fine for a bounded session. It is not a persistent connection, and treating it as one will produce a bug that looks like a network fault.
The options that hold a socket for hours:
| Where | Fits |
|---|---|
| A container or VM you run, Fly, Railway, Render, ECS, a droplet | Full control, ordinary Node/Go/Elixir, ordinary debugging. The default answer. |
| Cloudflare Durable Objects | Stateful single instance per room execution at the edge. Hibernatable WebSockets keep connections open while the object sleeps, which is the right cost model for many idle connections. |
| A managed realtime service, Ably, Pusher, PartyKit, Liveblocks, Supabase Realtime | You are buying the whole of this article. Correct choice more often than engineers like to admit. |
A pattern worth naming: keep the app on Vercel and put the socket server somewhere that can hold it. There is no rule that says one deployment target has to serve both, and pretending otherwise is how teams end up rewriting a product to fit a hosting constraint.
The honest default
Most features that feel like they need a WebSocket need SSE. Most that genuinely need a WebSocket need one server, a heartbeat, and a load balancer configured by somebody who read this far. The Redis backplane is for when you have two servers, not for when you imagine you might.
Build it in that order and every step is reversible. Build it the other way round and you are operating a distributed message bus to deliver typing indicators to four hundred people.
Sources for the figures above: WebSockets at Scale, WebSocket authentication, fixing WebSocket timeouts, scaling Socket.IO, WebSockets vs SSE, WebSockets on Vercel and Cloudflare's Durable Objects WebSocket guide.
Building something that has to be live?
Real-time features are one part of a web application. We scope and build the whole of it.



