DEV Community

Cover image for WebSocket Reconnects Are a State Machine: Build a Deterministic Interview Drill
Karuha
Karuha

Posted on

WebSocket Reconnects Are a State Machine: Build a Deterministic Interview Drill

A reconnect loop is not just call connect() again after a delay. It is a small state machine with a clock, cancellation, and an upper bound. In this drill, you will build one in plain Node.js and prove four contracts: backoff grows and caps, a successful connection resets the attempt count, stopping cancels pending work, and an old timer can never start a second connection.

That last contract is the one I listen for in interviews. Most reconnect bugs are not caused by the formula. They happen when two close events schedule two timers, or when a timer created before stop() still fires after logout.

What should a reconnect controller promise?

Before writing code, write the observable behavior:

  1. A closed socket schedules at most one reconnect attempt.
  2. Delays grow exponentially, but never exceed a cap.
  3. The first successful open returns the client to a clean baseline.
  4. stop() makes future callbacks harmless.
  5. A callback from an older schedule cannot change the current state.

This is deliberately smaller than a production WebSocket client. The point is to isolate the policy so that the policy can be tested without a real network, browser, or fake server.

A useful state vocabulary is:

  • idle: no connection attempt has started.
  • connecting: a connection attempt is in flight.
  • open: the socket is usable.
  • waiting: a reconnect timer exists.
  • stopped: the owner has cancelled the client.

Transitions should be explicit. If a timer callback can “just call connect,” you have hidden a transition and made it hard to reason about races.

A dependency-free controller

Save this as reconnect-drill.mjs and run it with Node 18 or newer:

import assert from "node:assert/strict";

function backoff(attempt, { base, cap, jitter = 0, random = Math.random }) {
  const exponential = Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
  const spread = exponential * jitter;
  return Math.round(exponential - spread + random() * spread * 2);
}

class ReconnectController {
  constructor({ base = 250, cap = 8_000, jitter = 0, random = () => 0.5 } = {}) {
    this.base = base;
    this.cap = cap;
    this.jitter = jitter;
    this.random = random;
    this.state = "idle";
    this.attempt = 0;
    this.generation = 0;
  }

  connect() {
    if (this.state === "stopped" || this.state === "connecting" || this.state === "open") {
      return { action: "ignored", state: this.state };
    }
    this.state = "connecting";
    return { action: "connect", state: this.state };
  }

  onOpen() {
    if (this.state === "stopped") return { action: "ignored", state: this.state };
    this.attempt = 0;
    this.state = "open";
    this.generation += 1; // invalidates every older timer
    return { action: "opened", state: this.state };
  }

  onClose() {
    if (this.state === "stopped") return { action: "ignored", state: this.state };
    this.attempt += 1;
    this.state = "waiting";
    this.generation += 1;
    const token = this.generation;
    return {
      action: "scheduled",
      state: this.state,
      attempt: this.attempt,
      delay: backoff(this.attempt, {
        base: this.base,
        cap: this.cap,
        jitter: this.jitter,
        random: this.random,
      }),
      token,
    };
  }

  fire(token) {
    if (this.state === "stopped" || token !== this.generation) {
      return { action: "ignored", state: this.state };
    }
    return this.connect();
  }

  stop() {
    this.state = "stopped";
    this.generation += 1;
    return { action: "stopped", state: this.state };
  }
}

// Contract 1: exponential delay grows, then caps.
const delays = [1, 2, 3, 4, 5].map((attempt) =>
  backoff(attempt, { base: 100, cap: 700, random: () => 0.5 }),
);
assert.deepEqual(delays, [100, 200, 400, 700, 700]);

// Contract 2: an open connection resets the next failure to the base delay.
const reset = new ReconnectController({ base: 100, cap: 700 });
reset.connect();
reset.onOpen();
const afterOpen = reset.onClose();
assert.equal(afterOpen.attempt, 1);
assert.equal(afterOpen.delay, 100);

// Contract 3: stopping makes a pending timer harmless.
const stopped = new ReconnectController({ base: 100, cap: 700 });
stopped.connect();
const pending = stopped.onClose();
stopped.stop();
assert.deepEqual(stopped.fire(pending.token), { action: "ignored", state: "stopped" });

// Contract 4: an older timer cannot win a race with a newer schedule.
const stale = new ReconnectController({ base: 100, cap: 700 });
stale.connect();
const first = stale.onClose();
const second = stale.onClose();
assert.deepEqual(stale.fire(first.token), { action: "ignored", state: "waiting" });
assert.deepEqual(stale.fire(second.token), { action: "connect", state: "connecting" });

console.log("reconnect state-machine assertions passed");
Enter fullscreen mode Exit fullscreen mode

Run it:

node reconnect-drill.mjs
# reconnect state-machine assertions passed
Enter fullscreen mode Exit fullscreen mode

The generation value is a cheap cancellation primitive. Every operation that invalidates a timer increments it. A callback carries the generation it was created with, and fire() refuses to act when the token is old. In a browser implementation, you would still call clearTimeout() for efficiency. The token check is the correctness boundary because a callback may already be queued when clearTimeout() runs.

Why the obvious implementation fails

A common first draft looks like this:

socket.onclose = () => {
  setTimeout(connect, 500);
};
Enter fullscreen mode Exit fullscreen mode

It has at least four failure modes:

  • Two close events create two timers and two simultaneous sockets.
  • A fixed delay causes many clients to reconnect together after an outage.
  • A successful connection does not reset the attempt counter, so a later short outage starts at a needlessly large delay.
  • A logout path cannot prove that a queued callback will not reconnect the user.

Exponential backoff reduces synchronized pressure, but it is not a complete strategy. In a real client, add a small random jitter, cap the delay, and decide whether the server can send a retry hint. Treat server-provided hints as untrusted input: clamp them to a policy maximum and record why they were accepted.

The policy also needs an owner. “Reconnect forever” is not a requirement; it is an accidental resource leak. A page hidden for hours, a signed-out account, or a mobile app in the background may need a stopped state. The owner should be able to cancel the loop and observe that cancellation in metrics.

How to narrate this in an interview

When asked to design reconnects, answer in this order:

  1. State the invariant. “There is at most one pending reconnect and at most one active socket.”
  2. Name the transitions. “Close moves to waiting; a valid timer moves to connecting; open resets attempts; stop invalidates all future work.”
  3. Give the timing rule. “Use capped exponential backoff with jitter, for example min(cap, base * 2^(attempt-1)).”
  4. Describe the race. “A stale timer can fire after a newer close or after logout, so every callback carries a generation token.”
  5. Choose the recovery boundary. “Reset the attempt count only after open, not after merely starting a connection.”
  6. Add observability. Log attempt number, chosen delay, close reason, and final stop reason. Do not log message payloads just to debug connectivity.

That explanation is stronger than reciting “use exponential backoff.” It shows you understand time, ownership, and failure ordering.

What would change in production?

The drill intentionally leaves out transport details. A production implementation still needs to answer these questions:

  • Is the handshake authenticated, and how are expired credentials refreshed?
  • Should messages be queued while waiting, or dropped with an explicit error?
  • Are outbound messages idempotent if a reconnect causes a retry?
  • Does the server close with a code that means “do not reconnect”?
  • How do multiple browser tabs coordinate so they do not all maintain a connection?
  • Which metrics distinguish a flaky client network from a server outage?

Keep those decisions outside the backoff function. That separation lets you test the policy with deterministic inputs and test the transport with a small number of integration cases.

For practice, record yourself explaining the four contracts, then replay the answer and challenge each assumption with a failure scenario. A tool such as aceround.app — AI interview assistant can generate follow-up questions, but the useful part is still the evidence: show the invariant, run the assertion, and explain the trade-off.

A compact checklist

Before shipping a reconnect loop, verify:

  • [ ] One active socket and one pending timer at most
  • [ ] Delay grows, includes bounded jitter, and has a cap
  • [ ] open resets the attempt counter
  • [ ] stop invalidates queued callbacks
  • [ ] Stale timers are rejected by a token or equivalent
  • [ ] Server retry hints are clamped
  • [ ] Close reasons and attempts are observable
  • [ ] Message replay semantics are explicit

The interview lesson is simple: a reconnect policy is a state machine with a clock. Once you write down the states and make stale work unconditionally harmless, the hard part becomes testable instead of hand-wavy.


Sources

Disclosure: I used an AI writing assistant to help outline and edit this article. I reviewed the code and claims, and the embedded Node.js assertions are intended to run as shown.

Top comments (0)