You step into an elevator. The internet drops out. Your WebSocket still says OPEN. Your app is convinced everything is fine. You ride down, step out, and the old WebSocket may still say OPEN. Welcome to distributed systems.
Here's the problem: the operating system doesn't have to tell your application immediately when a TCP connection stops carrying data.
Where the Trouble Starts
Here's our first mistake:
socket.onopen = () => setOnline(true)
The onopen event tells you the WebSocket has opened. It doesn't tell you whether the other end is still alive—or whether the service behind that connection is ready to do any work.
Projects built in a hurry tend to accumulate the same mistakes:
- Mistake #1. Reconnecting every N seconds.
- Mistake #2. Triggering reconnects from multiple event handlers.
-
Mistake #3. Treating
socket.openas proof that the connection is alive. - Mistake #4. Reconnecting at the push of a button.
And the worst part? These aren't made-up examples. I've seen code like this in production.
What's Actually Wrong?
An open socket doesn't mean everything is working. To tell whether a connection is actually usable, we need to check three things:
- Transport. Is the socket open?
- Protocol. Is the peer still alive?
- Service. Is the service behind the connection ready to handle our requests?
Each layer can fail even while the one below it keeps working. A load balancer can hold a TCP connection open while the server hangs. A server can respond to health checks while the service behind it is down.
And status isn't our only concern. We also need to handle requests left unanswered during a failure, and reconnect without hammering a server that just came back online.
The Story of One Client
Let's build a simple client and change it only when we encounter a specific failure.
If you'd rather start clicking things, the code is in the demo project. Run npm run dev to start a real WebSocket server, with buttons to trigger failures. You can switch between the stages covered here using ?stage=0 through ?stage=5.
Stage 0. The Naive Client: Trust the Socket Events
Our first client considers itself online after onopen and offline after onclose. When the connection closes, it reconnects one second later.
protected handleOpen() {
this.setLevels({ transport: 'up' })
this.subscribeToFeed()
}
protected handleClose(code: number, reason: string) {
this.setLevels({ transport: 'down' })
if (this.started) this.scheduleReconnect()
}
As long as the server is healthy, this works. Now let's enable the Freeze failure in the demo. The server pauses the TCP socket: the connection stays open, but data is no longer read or sent. To the browser, it's like someone leaving a party without saying goodbye—an Irish goodbye, TCP edition.
Client says: READY
Updates: not arriving
Requests: piling up without responses
Why doesn't the browser notice? If the peer disappears without closing the connection, the TCP packet signaling the shutdown never arrives. TCP keepalive doesn't save us here: browser JavaScript can't configure it, and on Linux the first probe is sent only after two hours of inactivity by default.
WebSocket has its own ping and pong control frames, but the browser handles them internally and doesn't expose them to JavaScript. Our client can neither send one of those pings nor observe the corresponding pong.
The naive client has another problem. Requests sit in a Map, waiting for a response for as long as it takes:
protected registerRequest(id: number, request: PendingRequest) {
this.pending.set(id, request)
}
If the connection dies before a response arrives, that promise never settles. The Send button stays in its loading state until the user refreshes the page.
So far, our client's status tells us only about the socket. Let's start with the requests that can hang forever.
Stage 1. The Request Registry: No Promise Left Hanging Forever
Request tracking is surprisingly easy to overlook. But the rule is simple: every request must eventually settle, even if no response ever arrives. That means a timeout and a way to reject every in-flight request when the connection closes.
protected registerRequest(id: number, request: PendingRequest) {
const timer = setTimeout(() => {
this.pending.delete(id)
request.reject(new RequestTimeoutError(`${request.method} got no response`))
}, REQUEST_TIMEOUT_MS)
// ...the timer is cleared on resolve and reject
}
protected handleClose(code: number, reason: string) {
this.rejectInFlight(new ConnectionClosedError(`Connection closed with ${code}`))
super.handleClose(code, reason)
}
The error types matter more than they might seem, because the calling code needs to react differently to each one:
| Error | What happened | Safe to retry? |
|---|---|---|
NotReadyError |
The request was never sent | Yes, always |
RequestTimeoutError |
No response; the request's outcome is unknown | Only if idempotent |
ConnectionClosedError |
The connection closed while the request was in flight; the outcome is unknown | Only if idempotent |
A request might have reached the server and completed successfully, with only the response getting lost. Automatically retrying it without an idempotency key could accidentally create a second order.
One more detail: request IDs keep increasing and aren't reset on reconnect. A late response carrying an old request ID won't find a matching entry in the registry, so it gets ignored.
Now requests settle, but our status is still wrong. Under Freeze, requests time out while the client keeps reporting READY. Next, let's find out whether the peer is actually alive.
Stage 2. Heartbeat: Is Anybody Still There?
As we've seen, browser JavaScript can't access WebSocket's protocol-level ping frames. So we move the liveness check into our application protocol. In the demo, the server sends a ping message immediately after connection and then once every second. The client considers the peer alive only after receiving the first ping, and starts a watchdog timer.
protected handleOpen() {
this.setLevels({ protocol: 'pending' })
this.armWatchdog(FIRST_PING_TIMEOUT_MS)
super.handleOpen()
}
protected handleMessage(message: ServerMessage) {
if (message.type === 'ping') {
this.armWatchdog(message.intervalMs * 2)
if (this.levels.protocol !== 'up') this.setLevels({ protocol: 'up' })
return
}
super.handleMessage(message)
}
The watchdog waits for two intervals, not one. A single late message—because of network jitter or a busy main thread—shouldn't tear down a working connection. The trade-off is that detecting a failure can take up to two intervals.
The server supplies the interval. That lets us adjust heartbeat frequency without shipping a new client release—for example, to reduce load when we have a large number of connections.
There's another option: the client sends ping and waits for pong.
| Model | Pros | Cons |
|---|---|---|
| Server sends, client listens | The server controls the interval; the client only listens | Doesn't verify the path from client to server |
| Client sends, server responds | Checks the path in both directions | The interval is baked into the client and harder to change |
Our demo uses the first model. It now detects Freeze in one to two seconds.
What to Do With a Dead Socket
The watchdog fires. We need to replace the connection. At first glance, we could just call close(), wait for onclose, and then reconnect.
But close() only initiates the closing handshake: the browser sends a close frame and waits for a response. A dead peer won't respond. In our Chrome measurement, onclose didn't fire for 60 seconds, eventually reporting code 1006.
Waiting a full minute is out of the question, so the client calls close() and moves on immediately:
protected forceReconnect(code: number, reason: string) {
this.socket?.close(code, reason)
this.handleClose(code, reason)
}
This looks reasonable, and it even works in the simple case. But the old socket's event handlers are still attached, and sooner or later its onclose will fire.
Heartbeat solved our failure-detection problem. Skipping the wait for onclose, however, introduced a new one: the client can now end up with two sockets at once.
Stage 3. Stale Events: Only the Current Socket Gets a Vote
Here's what happens on a timeline:
Old socket: ──open── close() ············ onclose (late)
↓ ↓
New socket: ──open────READY────────status reset!
↓
Extra socket: ──open────
The old socket's late onclose invokes the regular handleClose. The client doesn't know the event is stale, so it:
- resets the status of a healthy connection;
- rejects requests that are already running over the new connection;
- schedules another reconnect even though the current socket is working.
Nobody closes the current socket, so it stays open. In the demo, after 12 seconds of Freeze, a single tab holds 4 connections open on the server, and the count doesn't go back down. The next failure triggers reconnects from each of them: over 15 seconds of Flapping, the server sees 48 attempts instead of 12.
The fix comes down to one rule: only the current socket is allowed to change client state.
protected bindSocket(socket: SocketLike) {
super.bindSocket(socket)
const { onopen, onmessage, onclose } = socket
socket.onopen = (event) => {
if (socket === this.socket) onopen?.(event)
}
socket.onmessage = (event) => {
if (socket === this.socket) onmessage?.(event)
}
socket.onclose = (event) => {
if (socket === this.socket) onclose?.(event)
}
}
protected forceReconnect(code: number, reason: string) {
const stale = this.socket
// From this point on, anything the old socket says is ignored.
this.socket = null
stale?.close(code, reason)
this.handleClose(code, reason)
}
We don't need a generation counter here: object identity already tells us exactly which connection an event belongs to. It's the same race you can encounter in useEffect, when an older request finishes after a newer one. And the fix is the same: before applying a result, check that it's still relevant.
With that fix, the tab returns to one active connection. While the server is frozen, though, it can still see several sockets: old connections whose closing handshake can't complete. They no longer interfere with the client, but the server needs its own liveness checks to clean them up.
Transport and protocol status are now reliable. But a live peer still doesn't guarantee a working service.
Stage 4. Service Readiness: The Third Layer
Let's enable Service down. The server keeps sending ping, but the service behind it is unavailable: it reports that the subscription has been lost and stops responding.
Our Stage 3 client correctly sees that transport and protocol are working, so it reports READY. But here's the worse part: when the service comes back, updates still don't resume. The subscription is gone, and nobody subscribes again. The status is accurate for every layer we're currently tracking, yet the app remains starved of data until the connection eventually breaks.
So we give the service its own status. A subscription counts as active only after the server acknowledges it. Until that confirmation arrives, the client retries the subscription over the same socket:
protected subscribeToFeed() {
this.setLevels({ service: 'pending' })
super.subscribeToFeed()
clearTimeout(this.subscribeTimer)
this.subscribeTimer = setTimeout(() => this.subscribeToFeed(), SUBSCRIBE_TIMEOUT_MS)
}
protected handleMessage(message: ServerMessage) {
if (message.type === 'subscribed') {
clearTimeout(this.subscribeTimer)
this.setLevels({ service: 'up' })
} else if (message.type === 'unsubscribed') {
this.subscribeToFeed()
} else {
super.handleMessage(message)
}
}
Notice what we don't do: disconnect. There's nothing wrong with the socket, so tearing it down just to reconnect and repeat the initial liveness check buys us nothing—especially if the service is down for every client at once.
Readiness now depends on all three layers:
protected isReady() {
return (
this.levels.transport === 'up' &&
this.levels.protocol === 'up' &&
this.levels.service === 'up'
)
}
The dependencies between these statuses flow in only one direction. Losing transport resets everything above it. Losing a subscription doesn't touch transport or protocol:
Transport → is the socket open?
Protocol → are pings arriving?
Service → has the subscription been acknowledged?
UI → sees only the final result: ready
There's one more scenario I want to demonstrate, and it deliberately doesn't affect readiness. Under Slow replies, individual requests time out, but the subscription and ping keep working, so the status remains READY. The right response is to report a failure for that particular request, not declare the entire connection dead. If the app needs to show degraded performance, give degradation its own signal.
Our demo client can now assess its state correctly. But a service rarely has just one client, and thousands of clients reconnecting at once can become a problem of their own.
Stage 5. Reconnection Policy: Don't Finish Off the Server
Every previous stage reconnects after exactly one second. That's fine for one client. But after a server restart, all clients lose their connections at once—and all of them come back in the same second.
We can't reproduce a stampede of thousands of clients in one browser tab, so the demo includes a virtual-time simulation. It uses the same reconnection-policy code as the real client. The setup: 10,000 clients lose their connections simultaneously, the server stays unavailable for 10 seconds, and then it can accept 500 connections per second.
Four Policies
Fixed delay. Every client retries once per second.
delay = 1000
Exponential backoff. The delay doubles until it reaches a cap.
delay = Math.min(cap, base * 2 ** attempt)
Randomized start with linear growth. The first delay is random; subsequent delays increase by a fixed step.
delay = attempt === 0 ? random() * maxStart : previousDelay + step
Exponential backoff with full jitter. Each delay is chosen randomly between zero and the current exponential cap. This approach is covered in detail in AWS's Exponential Backoff And Jitter.
delay = random() * Math.min(cap, base * 2 ** attempt)
What Did the Simulation Show?
| Policy | Peak attempts per second after server recovery | Total attempts | Time until all connected |
|---|---|---|---|
| Fixed, 1 s | 10,000 | 204,000 | 30 s |
| Exponential, 1–30 s | 10,000 | 58,350 | Not within 90 s |
| Random start, +1 s | 2,040 | 63,235 | 35 s |
| Exponential, 1–30 s, full jitter | 1,540 | 53,501 | 51 s |
A few results are less obvious than they look.
Jitter doesn't eliminate the initial spike. With a base delay of one second, every client still makes its first attempt within that first second. The benefit comes later: when the server returns, full jitter brings the peak down to 1,540 attempts per second instead of 10,000.
Exponential backoff without jitter performs worse than a fixed delay. It reduces the total number of attempts, but the clients remain synchronized. Attempts arrive in waves at 1, 3, 7, 15, 31, and 61 seconds, each wave dumping every remaining client onto the server at once. The server accepts only a small fraction of those attempts, so by the 90-second mark, only about 1,650 of the 10,000 clients have connected. And this is exactly the kind of policy people often consider the sensible default.
Fixed delay reconnects everyone the fastest. In this model, 30 seconds is the theoretical minimum: 10 seconds of downtime plus 20 seconds to accept 10,000 clients at 500 per second. But that's a weakness of the model, not a virtue of the policy. In the simulation, a rejected connection attempt costs the server nothing. In reality, 10,000 attempts per second against a capacity of 500 consume resources on TLS handshakes and rejected connections—and can prevent the server from recovering at all.
A randomized start with linear growth works surprisingly well. In this scenario, it even reconnects everyone faster than full jitter. But during the first minute of downtime, it makes more attempts than exponential backoff, and its delay has no cap. If the server stays down for an hour, a client may still be waiting roughly another minute and a half for its next attempt after the server has recovered. A maximum delay is worth adding regardless.
Full jitter takes longer to reconnect everyone, but produces the lowest peak and the fewest total attempts. The cost is a longer wait for some individual clients.
When Should You Reset the Backoff?
Eventually, you have to reset the backoff. Otherwise, one outage can leave the client reconnecting slowly after every subsequent disconnection. The obvious place to reset it is onopen. But imagine a server that accepts a connection and immediately closes it:
open → reset → disconnect → short delay → open → reset → disconnect → ...
Each brief success wipes out the accumulated delay, and backoff stops doing its job. Instead, reset it only after the connection has stayed healthy for a specified amount of time:
protected setLevels(levels: Partial<Levels>) {
super.setLevels(levels)
if (!this.isReady()) {
clearTimeout(this.cooldownTimer)
this.cooldownTimer = undefined
} else if (this.cooldownTimer === undefined) {
this.cooldownTimer = setTimeout(() => this.backoff.reset(), COOLDOWN_MS)
}
}
The clock starts when the client becomes ready, not when onopen fires. A connection that opens but never receives subscription confirmation doesn't count as a success.
In the simulation, with a server that closes connections after 300 ms:
| Policy | Reset on open: connections per minute | Reset after 5 s of readiness |
|---|---|---|
| Exponential, 1–30 s | 46 | 5 |
| Random start, +1 s | 77 | 10 |
| Exponential, 1–30 s, full jitter | 77 | 8 |
Device Network State
If the device is offline, reconnect attempts are pointless and only increase the backoff delay. The client listens for offline and online events. On offline, it cancels scheduled reconnects. On online, it resets the backoff and tries again after a small randomized delay.
These events reliably tell us only when network connectivity is absent. An online event doesn't guarantee that the server is reachable, so it triggers a normal connection attempt rather than changing readiness status. We don't need special handling for a laptop waking from sleep: the watchdog from Stage 2 will notice that pings haven't arrived in a while.
The reconnection policy protects the service, but it comes at a cost to the user. In our run, recovery after a restart took about 2.4 seconds at Stage 5, compared with less than a second in the simpler stages. That's a deliberate trade-off.
Measurement Results
I ran every stage against the same failure scenarios on a real server: headless Chrome, a production build, and one run per stage. Timings depend on the environment and exactly when a failure occurs, so the relative behavior matters more than the precise numbers.
| Stage | Freeze detected | Service outage detected | After service recovery | Server-side connections after Freeze | Attempts during 15 s of Flapping |
|---|---|---|---|---|---|
| 0. Naive client | Never | Never | No data | 1 | 12 |
| 1. Request registry | Never | Never | No data | 1 | 12 |
| 2. Heartbeat | ~1.2 s | Never | No data | 4 | 48 |
| 3. Stale events | ~1.0 s | Never | No data | 1 | 12 |
| 4. Service readiness | ~1.1 s | Immediately | Data within 2 s | 1 | 12 |
| 5. Reconnection policy | ~1.1 s | Immediately | Data within 2 s | 1 | 6 |
Stage 2 is the most interesting row: adding heartbeat made the client more observant and simultaneously made things worse for the server. Fixing one problem exposed another, and the only way to spot it was to watch the server-side connection count.
These measurements have limitations. Freeze simulates a silent peer by pausing a socket on the server; it isn't an actual network outage. The 60-second figure was measured only in Chrome and may differ in other browsers. We also didn't measure watchdog behavior in background tabs, where browsers may throttle timers.
Practical Advice
Not every app needs all of this machinery. The right set of safeguards depends on your reliability requirements, but I'd start in roughly the same order:
- Make sure every request settles. Always use timeouts and reject in-flight requests when the connection closes. It's only a few lines of code; without them, the UI can hang indefinitely.
- Add an application-level heartbeat. Without it, the client may never notice a dead connection. Prefer letting the server specify the interval.
-
Don't wait for
oncloseon a dead socket, and ignore events from stale sockets. These two changes need to go together. - Separate service readiness from connection liveness. Require subscription acknowledgments and restore subscriptions without tearing down a healthy socket.
- Add jitter and a maximum backoff delay. Reset backoff only after the client has remained ready for a defined period.
- Monitor connection counts on the server. Extra connections from a single tab won't show up in the client's UI, but they will show up in server metrics.
- Test failure modes, not just network disconnects. A clean disconnect is the easy case: the browser tells you about it. Test hangs, service outages, and flapping connections too.
In the demo, the stages form a chain of subclasses so that each stage's file represents exactly one fix. In production, it's usually cleaner to separate responsibilities into layers: socket management and reconnection, liveness checks and request tracking, subscriptions, and the final readiness state exposed to the UI.
onopen answers only the easiest of our three questions. A reliable client doesn't trust a single event. It checks whether the peer is alive, whether the service is working, and whether its own recovery behavior might cause the next outage.
Code for this article: godfreyd/websocket-readiness.
Top comments (0)