Skip to content
11 min read

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.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “The socket reconnected. The subscription did not.”
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
    1. 3 s server down
    2. 2.13 s reconnect, re-subscribe, first position
  • client subscribes once, on mount
    1. 3 s server down
    2. the socket came back and the subscription did not — 0 of 40
One outage, two lifelines, one scale. The transport is a solved problem and the library solves it; the subscription is server-side state attached to a socket that no longer exists, and nothing in the client is watching for that.

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%
Eighty reconnects, none of which came back, with the socket reporting connected throughout. The control arm — the same client re-emitting its subscribe inside the connect handler — is what makes that zero a finding rather than a broken harness.

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

It sends 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

  1. 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.
  2. Subscribe in the connect handler, and only there. It runs on the first connection and every reconnection, which makes the mount-time subscribe redundant rather than complementary.
  3. 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.

Was this helpful?

// Build it

Running this in production?

Telemetry fails quietly — a timestamp that never reached the wire, a track you cannot draw, a fleet that reconnects all at once. Tell us what your map is showing and when; we reply within a day with a concrete next step.