Skip to content
12 min read

The deploy that logs every driver back in at once

For a request/response API a missing shutdown handler costs a handful of failed requests. For a socket system it costs a synchronised reconnect of every connected client, each one re-running an authorisation query — and none of it shows up in the metrics you already have.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “The deploy that logs every driver back in at once”
On this page

TL;DR: Graceful shutdown for websockets is a different problem from graceful shutdown for an HTTP API, and the difference is not one of degree. A request/response service that exits without draining loses the handful of requests in flight. A socket service that exits without draining disconnects every client it has, simultaneously — and each of them reconnects, and each reconnect re-runs whatever work you do on connect. The reconnection library's defaults spread that stampede across about one second, which looks like a solution and is not.

Three facts about a system, each of them individually unremarkable.

The process has no signal handling: no SIGTERM handler, no SIGINT handler, nothing that runs between "the orchestrator wants this container gone" and the process ending. The deploy replaces the container outright rather than draining it. And every client that connects runs an authorisation query on connect, to work out which orders that driver is allowed to watch.

Each of those is ordinary. Composed, they mean that every deploy is a synchronised, fleet-wide re-authorisation, and that nothing in your monitoring is shaped to show it to you.

Why a socket service is a different problem

The instinct that makes this easy to miss is a correct instinct about a different architecture.

For a request/response API, a missing shutdown handler has a bounded and well-understood cost. The requests in flight at the moment of exit fail; there are usually a few dozen; the client retries or the user refreshes; your error-rate graph shows a spike one scrape wide and returns to baseline. Everyone has seen that spike. It is annoying rather than serious, and adding the handler is on somebody's list.

A socket service does not have "requests in flight". It has state that exists because the connection exists. Killing the process does not fail some work — it invalidates every relationship the server was holding, all at once, for every client. And every one of those clients is written to notice and reconnect, because that is what makes a socket system usable in the first place.

Request/response API Socket service
What is lost on exit the requests in flight every connection, and the state each implied
How many clients notice those mid-request — a few all of them, simultaneously
What the client does next retries one request, or nothing reconnects, and re-establishes everything
What the reconnect costs the server one request's work a connect handshake plus whatever runs on connect
Where it shows in your metrics the HTTP error rate nowhere, unless you built it

That last row is the one that keeps this invisible. Nothing about a websocket reconnection storm passes through the HTTP request pipeline your dashboards are built on. There is no 5xx. There is no elevated request latency, because there are no requests. The work is real — every reconnecting client runs an authorisation query — and it lands in a shape your instruments were not made to see.

The defaults look like they solved this

Here is the part I find genuinely interesting, because it is not an absence of care.

The client sets no reconnection options at all, so it inherits the library's defaults. It would be easy to write that up as "nothing is staggered", and it would be wrong. The Socket.IO client defaults to a reconnectionDelay of 1000 ms with a randomizationFactor of 0.5, which means each client waits a value drawn from a window around that base:

// the FIRST reconnect attempt, per client
const delay = 1000 * (1 + (Math.random() - 0.5)) // 500-1500 ms

Later attempts back off — the documented ladder runs 500–1500 ms, then 1000–3000, then 2000–5000, to a five-second ceiling. Which sounds like it spreads things out, and after a deploy it does not: the server is back within seconds, so essentially every client lands on attempt one and never reaches the wider rungs. The narrow window is the only one that runs.

So there is jitter. It is deliberate, it is on by default, and it was put there by people who understood exactly this failure. It is simply sized for the problem it was written for: keeping a handful of browser tabs from retrying in lockstep after a blip.

Point it at a fleet and the arithmetic stops working. Every client that was connected wakes up inside the same one-second window, which for a system with a device per vehicle means the entire fleet re-authorises inside a single second. A one-second spread across a fleet is not a stagger; it is a rounding error on simultaneity. The defaults are the reason nobody looks: the option exists, it has a sensible value, and the box is ticked.

How wide does the window need to be?

Wide enough that the peak is a rate your authorisation path can serve without queueing, which makes it a division rather than a preference.

Take the number of connected clients, divide by the connect-time work rate you can actually sustain, and that quotient is your minimum window. A fleet of a few hundred devices against an authorisation query you would rather not run more than a few dozen times a second is already a window of tens of seconds, not one. And it scales with the fleet, which is the property that matters: a stagger tuned by hand today is wrong the first time the customer grows.

Two corollaries fall out of that, and both are more important than the number:

The window belongs to the server, not to the client's defaults. The server knows how many clients it had and what its own connect path costs; the client knows neither. A client-side constant is a guess made in the wrong place, by someone without the inputs.

Reconnect delay and the work on connect are one problem. Halving the connect cost and doubling the window buy the same relief. It is worth checking whether the authorisation query needs to run on every connect at all, or whether its answer can outlive the connection it was computed for — because the cheapest stampede to survive is the one that does less work per arrival.

Configured jitter is worse than none when it is the wrong size

Socket.IO’s defaults already stagger reconnects across roughly 500–1500 ms, and that is genuinely enough to stop a handful of browser tabs retrying in lockstep. Point it at a device-per-vehicle fleet and every client wakes inside the same second. The danger is not the absence of a stagger — it is that the option exists, holds a sensible-looking value, and therefore never gets questioned.

What does the narrow window actually cost?

The division above is arithmetic, and arithmetic is easy to nod at and not act on. So I staged the storm.

A bounded connect path — ten concurrent authorisations of 20 ms each, which is a connection pool in front of an indexed query, and comes to 500 connects per second sustainable. Then a fleet of real sockets, each client waking at its own draw from the window and connecting for real. The jitter shape is the library's, not an invention. The work on connect is a calibrated CPU burn rather than a real query, because what the handler does changes none of the queueing; the service time is published with the numbers so you can recompute them against your own.

And it is one machine talking to itself. Every one of those sockets is a loopback connection from the same process, so there is no network latency, no TLS handshake, no load balancer deciding where each client lands, and no connection that fails and has to try again. This measures queueing at the accept path and nothing else — which is the quantity the argument turns on, and also the reason to read every number below as a floor. A real fleet arrives over links that add all four.

Fleet Window p50 p95 Slowest Whole fleet back
500 library default 57 ms 124 ms 134 ms 1.6 s
500 5 s 19 ms 23 ms 34 ms 7.5 s
2 000 library default 1.65 s 3.2 s 3.3 s 4.8 s
2 000 5 s 24 ms 43 ms 61 ms 7.5 s
10 000 library default 10.5 s 20.4 s 21.5 s 23.0 s
10 000 5 s 9.1 s 17.5 s 18.5 s 26.0 s
10 000 30 s 23 ms 34 ms 56 ms 45.0 s
10 000 120 s 19 ms 26 ms 40 ms 180.0 s

The division holds, and it holds sharply. Five hundred clients into a one-second window is 500 arrivals per second against 500 of capacity — right at the line, and the p50 is already close to three times the service time. Two thousand is four times over, and the median client waits 1.65 seconds for work that takes twenty milliseconds. Ten thousand is twenty times over and the median waits ten and a half seconds, with the unlucky tail past twenty.

Then look at the last two rows. The predicted minimum window for ten thousand clients is 10 000 ÷ 500 = twenty seconds — and thirty seconds brings the median back to 23 ms, essentially the service time, while five seconds barely helps at all. The prediction was written before the run; the run put the transition exactly where the division said it would be.

Nothing here is the network's fault and nothing appears in a request metric. The harness counts failed connections in its own column and that column reads zero in every arm — nothing errored, nothing was refused, nothing timed out. Every one of those clients was simply waiting, and the only place waiting shows up is a metric somebody deliberately built.

The trade is in the last column, and it is the reason this is a decision rather than a default. A wide window flattens latency and stretches full recovery: at 120 seconds no client waits more than 40 ms, and the fleet is not whole for three minutes. A narrow window has everyone back fast, in the sense that they are all back in the queue. Which of those you want depends on what the clients do while they wait — and that is a question about your product, not about your reconnect settings.

  • 120 s window180 s
  • 30 s window45 s
  • 5 s window26 s
  • library default (0.5–1.5 s)23 s
Ten thousand clients against a connect path good for five hundred a second. Widening the window is not free: it flattens the median wait and stretches the tail, and at two minutes the last client is still coming back three minutes after the deploy.

What a graceful socket shutdown actually looks like

The missing piece is draining, and it is three steps rather than one.

Stop accepting new connections, but keep serving the ones you have. For Socket.IO this is io.close(), which closes the underlying server to new connections; the important half is that existing sockets keep working while you do the rest. On a request API this step is the whole of graceful shutdown. Here it is the setup.

Tell the clients to go, with a spread you choose. This is the step that has no equivalent in the HTTP world and the one people skip. Emit an application-level "reconnect after N" and let the client schedule against a number the server picked, drawn across a window sized to the fleet. If the client ignores it and falls back to its own defaults, you have lost nothing — but the clients that honour it are the ones that stop being a stampede.

Then exit, on a deadline. Wait for connections to drain, and stop waiting after a bounded time, because a shutdown that can hang is a deploy that can hang. The orchestrator has its own patience — Kubernetes sends SIGKILL after terminationGracePeriodSeconds — and your deadline should sit inside it, otherwise the graceful path you wrote is the one that never finishes.

None of that is much code. What it needs is a signal handler, which is the thing that was missing at the start.

  1. Stop acceptingexisting sockets keep working
  2. Tell clients whena window the SERVER sized
  3. Exit on a deadlineinside the orchestrator's
Only the first and last steps have HTTP equivalents. The middle one is the whole difference, and it is the one that gets left out.

FAQ

We deploy with a rolling update, so connections move over gradually. Are we fine? Better, but the mechanism is the same one scaled down. A rolling update replaces pods in batches, so each batch disconnects its share of the fleet at once — a smaller stampede, several times over, and possibly onto replicas that are still coming up. What rolling updates fix is total unavailability. What they do not fix is a connect path that is expensive per client, because that cost is paid per reconnection either way.

Would a sticky load balancer or a shared session store help? They solve a different problem — which server a reconnecting client lands on, and whether its state survives the move. Valuable, and orthogonal: even with perfect session affinity and shared state, a process that dies without draining still disconnects everyone at once, and they still all come back inside the same window. Fix the shutdown first; it is smaller work and it is upstream of the rest.

How would we tell whether we have this? Two greps and a read, and it takes about ten minutes. Search your server entry point for a signal handler; if there is none, the container is being killed rather than asked to stop. Search your client for reconnection options; if there are none, you have the library's defaults and you should read what they actually are rather than assuming. Then read the connect handler and ask what it does per connection, because that is the quantity being multiplied by your fleet.

Should we just not re-authorise on connect? Not "just" — but do make it a decision rather than an inheritance. Authorising on connect is the correct default: it is the moment you know who is asking. The question is whether the answer has to be recomputed from scratch every time a connection drops and returns, which on a mobile fleet is often, for reasons that have nothing to do with the driver's permissions changing.

The three things worth taking away

  1. A missing shutdown handler is a different bug in a socket service. In an HTTP service it costs the requests in flight. Here it costs every connection at once, and the cost lands on the connect path rather than in your request metrics.
  2. Default jitter is real and sized for browser tabs. Around one second of spread stops a handful of clients retrying in lockstep. For a fleet it is a rounding error on simultaneity — and worse than nothing, because the option looks configured.
  3. Draining is three steps, and the middle one has no HTTP equivalent: stop accepting, tell the clients when to come back with a spread you chose, then exit on a deadline that sits inside the orchestrator's.

The other half of this system's story — what it does with the positions those reconnecting clients report — is the timestamp that was never on the wire.

The wider shape this belongs to — what a fleet costs you between the field device and the dispatcher's screen — is on freight telemetry that survives a bad link.

If you are running a socket system and have never watched a deploy from the clients' side, 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.