The socket reconnected. The subscription did not.
A dropped websocket comes back on its own — that is what the library is for. What does not come back is the room it was in, because rooms belong to the socket that left. The connection reads healthy and the screen is blind.
On this page
TL;DR: A dropped websocket reconnects on its own — that is what the reconnection logic is for, and it works. What does not come back is the subscription. Rooms are server-side state attached to a socket, and a reconnect produces a new socket that has never asked for anything. So the client reports connected, every indicator reads healthy, and the screen quietly stops updating. We measured it: socket reconnect subscription loss happened in all eighty reconnects, with the client reporting connected throughout — and in forty further trials the client never reconnected at all, which is a second failure with a different fix.
Socket reconnect subscription loss is the most under-discussed failure in real-time UI, and the reason is that it does not look like a failure from any angle you normally check.
The connection indicator is green, because the connection is genuinely up. The server is healthy and serving. The client is not throwing. There is no failed request, because there are no requests — the data was arriving over a socket, and it has simply stopped arriving. The only symptom is an absence, and absences do not page anyone.
Three ordinary decisions that compose into it
None of these is a mistake on its own. That is what makes the composition worth naming.
The client subscribes once, when the view mounts. It opens an order, emits a subscribe for that
order's room, and starts receiving positions. Perfectly reasonable: you subscribe when you start
caring, and unsubscribe when you stop.
The reconnection is handled by the library, so nobody wrote any. Socket.IO reconnects on its own defaults, and it does it well — this is one of the main reasons to use it. Nothing in the application handles the disconnect, because nothing needs to.
Nothing runs on connect after the first one. No cache invalidation, no refetch, no
re-subscribe. And in the system I know best there is not even an incidental catch-up, because the
order view reads its data from a loader rather than from a query — so there is no stale-query
mechanism to notice that anything was missed.
Compose those three and the failure is not an edge case; it is the only possible outcome. The client will reconnect, and it will reconnect into nothing.
Why doesn't the room come back with the connection?
Because a room is not a property of the client. It is server-side state attached to one socket, and that socket is gone.
The Socket.IO rooms documentation is explicit about the shape:
socket.join(room) puts that socket in the room, and the socket leaves every room it was in when it
disconnects. A reconnecting client is not the same socket resuming — it is a new connection with a
new id, and from the server's point of view a fresh arrival that has not asked for anything.
That is the whole mechanism, and it is worth stating plainly because the intuition runs the other way.
Reconnection feels like resumption. The library calls the event connect both times, the client
object is the same object, its state survived, and the code that runs on reconnect is the code you
wrote for the first connect — which, if you only subscribed on mount, is nothing.
So the server is not broken and neither is the client. The server is doing exactly what it was asked: broadcasting to a room that this socket is not in. The client is doing exactly what it was written to do: waiting for messages it never asked for again.
- client re-subscribes on connect
- 3 s server down
- 2.13 s reconnect, re-subscribe, first position
- client subscribes once, on mount
- 3 s server down
- the socket came back and the subscription did not — 0 of 40
How often, and does the mechanism matter?
Both questions were the point of measuring rather than reasoning, so here is the run.
Real socket.io and socket.io-client, real rooms, forty trials per arm, and the library's own
reconnection settings — no client options are set, so the backoff is the 1 s / 5 s / 50% jitter a
reader gets out of the box. The metric is deliberately generous: did any position arrive within
five seconds of the client reporting connect? A single frame is enough to falsify the claim.
What it is not: one process, one server instance, no adapter, no load balancer, no TLS and no network latency. Every one of those makes recovery worse rather than better, so read these as a floor. And it did not run against the platform whose code this article describes — the question is server-side room state against a client-side re-emit, and both live inside one library, so the answer is the same on loopback as across an ocean. Running it on someone else's box would have added an SSH session and no information.
| Client | Outage mechanism | Subscription live | 95% CI | Blind for |
|---|---|---|---|---|
| subscribes once, on mount | disconnect(true) from the server |
0% | 0–9% | no reconnect at all |
| subscribes once, on mount | transport close | 0% | 0–9% | never returns |
| subscribes once, on mount | restart, 3 s outage | 0% | 0–9% | never returns |
re-subscribes on connect |
disconnect(true) from the server |
0% | 0–9% | no reconnect at all |
re-subscribes on connect |
transport close | 100% | 91–100% | 1.08 s |
re-subscribes on connect |
restart, 3 s outage | 100% | 91–100% | 5.13 s |
Eighty reconnects, none of which came back. Not "sometimes", not "under load" — in every trial where the client reconnected at all, it reconnected into no room, and reported itself connected while doing so. The other forty trials are the first and fourth rows, where the client never reconnected; that is a different failure and it gets its own section below. Pooling the two into one number would be the mistake this article is about.
The control arm is what makes that zero believable. A client that re-emits its subscribe inside
the connect handler returns at 100%. Without that row the zero would more likely mean a broken
harness than a broken system, which is exactly how a measurement like this usually goes wrong.
And the last column is the part worth taking to a planning conversation, because it does not behave the way people assume. A transport close is instantaneous — nothing is down — and the map is still blind for 1.08 s, which is the first backoff attempt and nothing else. A three-second outage costs 5.13 s: the outage, plus roughly two more seconds, because the attempt that would have succeeded had already fired and failed, and the next rung is further away than the last one.
So blindness grows faster than the outage does. Three seconds of downtime bought five seconds of a wrong map, and a longer outage lands on a later rung still — that is what exponential backoff is for, and it is working correctly. Which means the reconnect is a real part of the cost, and the thing to size is not the deploy window but the deploy window plus the rung it lands you on.
- re-subscribes on connect · transport close100%
- re-subscribes on connect · 3 s restart100%
- subscribes on mount · transport close0%
- subscribes on mount · 3 s restart0%
The mechanism decides which failure you get
Look at the first and fourth rows again, because they are not the same failure as the others and they do not have the same fix.
A disconnect(true) issued by the server sends an io server disconnect, and the client treats that
as final: it does not attempt to reconnect at all. So the row reads 0% for both clients, and
re-subscribing cannot help, because there is no connect event to re-subscribe in. The screen is not
blind — the client is simply gone until something calls connect() by hand.
The other two mechanisms — a transport that dies without a goodbye, and a server restart — both produce a client that comes back into no room at all.
That distinction is the reason to state which mechanism produced a number instead of publishing an average of three. A single "reconnect recovery rate" would blend a client that is blind with a client that is absent, and the two need different fixes: one is a handler, the other is a policy about when a server is allowed to hang up on someone.
And it changes the duration too, by a factor of five — 1.08 s against 5.13 s on the two mechanisms where the fixed client does come back. So a recovery figure quoted without its mechanism is not merely imprecise; there is no single number there to be imprecise about.
The fix, and where it has to live
One line, in the one place that runs every time:
// wrong: runs once, when the view mounts
useEffect(() => { socket.emit("subscribe", orderId) }, [orderId])
// right: runs on every connect, including reconnects
socket.on("connect", () => socket.emit("subscribe", orderId))Subscribe in the connect handler, not on mount. The handler fires on the first connection too,
so this is not an addition to the mount path — it replaces it, which matters, because two places
that both subscribe is how you end up with a double subscription nobody notices until a duplicate
render.
Three things worth doing alongside it:
Make re-subscription idempotent on the server. join is already idempotent for a given socket, but
your own bookkeeping may not be. A client that reconnects three times in a bad minute should cost the
same as one that connects once.
Refetch on reconnect as well as re-subscribe. The socket carries changes; it does not carry the state you missed while disconnected. Re-subscribing resumes the stream from now, and whatever happened during the gap is simply absent unless something fetches it.
Alert on the subscription, not the socket. A green connection is the thing that hid this failure in the first place. What can be observed is the arrival of data: a per-entity "last message received" that a monitor can watch is the counter that actually binds.
A server-side disconnect(true) is not the same failure
io server disconnect, which socket.io-client treats as final: the client does not reconnect at all. Re-subscribing cannot help, because no connect event ever fires. That is a decision about when your server may hang up on someone, not a bug to fix in a handler — and averaging it together with the other two mechanisms produces a number that describes neither.FAQ
We use a heartbeat and it stays healthy. Does that rule this out? No — a heartbeat is a property of the transport, and the transport is fine. Ping and pong flow over a socket that is in no rooms exactly as they flow over one that is, so a heartbeat monitor confirms the half of the system that was never broken. The only signal that distinguishes them is application data arriving, which is why the alert has to be on the payload rather than the pipe.
Would a Redis adapter or sticky sessions fix it? No, and it is worth being clear because both are the usual answers to "reconnect problems". They solve which server a reconnecting client lands on, and whether server-side state is shared across instances. Neither re-issues a subscription the client never re-sent. In fact multiple instances make the failure slightly more likely to be noticed, because the client may land somewhere that was never even nearby.
How would we tell whether we have this? Open a view that updates live, kill the network for thirty seconds, and restore it. If the connection indicator returns to healthy and the data does not resume, you have it. That takes a minute and does not need a harness — the harness exists because "it happened once and we could not reproduce it" is not a thing you can write down.
Does the same apply outside Socket.IO? The mechanism does, because it is not about the library. Any system where a subscription is server-side state keyed to a connection has it: a raw WebSocket with a topic map, an SSE stream with a filter, an MQTT session without persistent subscriptions. What differs is only whether the library gives you a place to put the re-subscribe, and how loudly it tells you the connection changed.
The three things worth taking away
- Transport recovery is not application recovery. The library restores the connection, which is its job and it does it well. Nothing restores the subscription, because the subscription belonged to a socket that no longer exists.
- Subscribe in the
connecthandler, and only there. It runs on the first connection and every reconnection, which makes the mount-time subscribe redundant rather than complementary. - A server-initiated
disconnect(true)is a different failure, not a worse one. The client does not come back at all, and no amount of client-side care changes that — so it is a decision about when your server is allowed to hang up, not a bug to fix in a handler.
The field this whole block turns on — a position that cannot say when it was taken — is the timestamp that was never on the wire.
What this looks like from the buyer's side, together with the three other places the same system goes quiet, is on freight telemetry that survives a bad link.
If your map has ever stopped updating while everything said it was fine, that is the kind of thing we come in for.