Under the Hood
Frontend

Modeling connection state as a machine, not booleans

Four booleans — isConnecting, isConnected, isError, isReconnecting — can be true and false in any combination, including ones that don't make sense, and nothing in that shape stops it. This lesson replaces them with one variable whose type is a fixed set of named states, walks the real WebSocket lifecycle those states model, and works through exponential backoff with jitter as the reason the transition out of a dropped connection isn't just "try again."

Modeling connection state as a machine, not booleans

You're building a chat UI backed by a WebSocket, and you need to show the user something honest about the connection: connecting, connected, reconnecting after a drop, gone for good. The obvious first move is a handful of booleans sitting next to each other in state — isConnecting, isConnected, isError, isReconnecting. Each one is easy to set, easy to read, easy to explain in a code review.

It's also the wrong shape, and the reason is precise: nothing about four independent booleans prevents them from describing a reality that can't happen. isConnecting: true and isConnected: true at the same time. isError: true while isReconnecting: true, with no way to tell which one is actually driving what the UI should show. These aren't hypothetical edge cases you might hit on a bad day — they're states your own type system is perfectly happy to construct, because four booleans have sixteen possible combinations and your connection only ever has a handful of real ones.

The fix this lesson works through: collapse the four flags into one variable, typed as a fixed, named set of states, with explicit rules for which transitions are legal from each one. Once that's in place, walk the real lifecycle events — connect(), onopen, an unexpected onclose, a deliberate .close(), a backoff timer — that drive the machine, and then the part that actually needs real reasoning, not folklore: why the retry delay after a drop has to grow, and why it has to have some randomness mixed in on top of growing.

Why four booleans is the wrong shape

Say you write the naive version:

function useConnection() {
  const [isConnecting, setIsConnecting] = useState(false);
  const [isConnected, setIsConnected] = useState(false);
  const [isError, setIsError] = useState(false);
  const [isReconnecting, setIsReconnecting] = useState(false);

  // ...somewhere, on connect attempt:
  setIsConnecting(true);
  setIsConnected(false);
  setIsError(false);

  // ...somewhere else, on the socket's onopen:
  setIsConnecting(false);
  setIsConnected(true);

  // ...somewhere else again, on an unexpected close:
  setIsConnected(false);
  setIsReconnecting(true);
}

Each individual line looks reasonable in isolation. The problem shows up at the seams. Every call site is manually responsible for clearing the flags that are no longer true, and nothing enforces that responsibility — the compiler doesn't know isConnecting and isConnected are supposed to be mutually exclusive, because as far as it's concerned they're two unrelated booleans that happen to live near each other. Miss one setIsConnecting(false) in one code path — an early return, a caught exception that skips the rest of the handler, a teammate adding a fifth transition six months from now who doesn't know all four call sites — and you've silently created a state where isConnecting and isConnected are both true. Nothing throws, nothing warns, and the bug sits there until some component reads both flags and has to guess which one is real.

That guessing is the second cost. Every place that reads this state — a connection badge, an error toast, a button that disables itself while connecting — has to independently decide what to do with a combination nobody planned for. Is isError && isReconnecting an error actively being retried, or a stale flag from three attempts ago that never got cleared? The booleans don't say, and different readers can reasonably reach different, inconsistent answers, because nothing in the data rules any combination out.

The fix: one variable, a fixed set of names, explicit transitions

The fix isn't "be more careful setting booleans." It's structural: replace four independent flags with a single variable whose type is a closed set of named states.

type ConnectionState =
  | "idle"
  | "connecting"
  | "open"
  | "reconnecting"
  | "closed";

This isn't a stylistic preference over booleans — it changes what's representable. With four separate booleans, "connecting and connected simultaneously" is a combination the shape allows and a bug you have to prevent by discipline. With a single ConnectionState variable, that combination isn't unlikely or discouraged — it's not expressible. A variable can only hold one value at a time, so there is no way for state to be simultaneously "connecting" and "open". The invariant ("only one of these is true at once") moves out of something programmers have to remember and into something the type itself guarantees.

The second half of the fix matters just as much as the enum: the set of states means nothing without explicit rules for which states can move to which others, on which named events. That's a transition table, worth writing down as data rather than scattered across if statements:

FromEventTo
idleconnect() calledconnecting
connectingonopen firesopen
connectingonerror / connect attempt failsreconnecting
openonclose fires unexpectedlyreconnecting
openapp calls .close() deliberatelyclosed
reconnectingbackoff timer elapses, retry attempt startsconnecting
reconnectingmax attempts exceeded, or app gives upclosed

Everything not in that table is, by definition, not a legal transition — there's no row for closed → open, so nothing in the system should produce it, and if something does, that's a bug you can now detect against a table instead of arguing about intent. The table (or the diagram below, which is the same information) is the source of truth for what's allowed, written down in one place instead of implied by however many call sites happen to touch the connection.

Notice the two separate edges leaving open. open → reconnecting fires when the socket goes away for a reason the app didn't choose — the server restarted, a proxy dropped the connection, the user's wifi blipped. open → closed fires when the app itself called .close() on purpose — the user navigated away from the chat screen, or logged out. Collapsing these into one edge is exactly what the boolean version tempted you to do, because both cases just look like "the socket stopped being open." But they need different code on the other side: an unexpected drop should trigger the whole reconnect-and-backoff machinery, while a deliberate close should not — retrying a connection nobody wants open anymore is its own bug. Naming the two edges separately is what makes it obvious they need different handling.

Walking the real lifecycle

Trace an actual session end to end against the table above. The connection starts in idle — nothing has happened yet. The app calls its own connect() function, which opens the underlying WebSocket; state moves to connecting. If the socket's onopen handler fires, state moves to open, and this is the only state in which it's correct to actually send and receive messages — code that sends a message while the state is anything else is trying to talk over a connection that isn't there yet or isn't there anymore.

Now the interesting part: at some point onclose fires on that open socket, and the app didn't call .close() itself. That's the unexpected-drop edge, open → reconnecting. The machine doesn't retry immediately — it starts a backoff timer (the next section is entirely about why), and only when that timer elapses does it fire another connect attempt, the reconnecting → connecting edge, landing back in the same connecting state the first attempt used. If onopen fires this time, you're back to open and the loop closes cleanly, backoff counter reset to zero. If it fails again, you're back in reconnecting, and the delay for this attempt is larger than the last. That escalation — looping between connecting and reconnecting, potentially many times — is exactly the loop the diagram's two edges between those states are drawing.

Eventually one of two things ends the loop: either a connect attempt succeeds and you're stably open again, or the app decides enough attempts have failed and moves to closed deliberately, giving up rather than retrying forever. That give-up transition and the "user asked to disconnect" transition both land on closed, but they're different events for a reason — one is the app deciding it can't reach the server, the other is the app deciding it doesn't need to.

Exponential backoff: why the delay has to grow

The naive instinct after an unexpected drop is to reconnect immediately — the connection just failed, so try again right away. That's precisely wrong when the connection failed because the server is down or overloaded. An immediate, unthrottled retry from every disconnected client adds load to a system that just demonstrated it can't handle its current load, at the exact moment it's least able to absorb more, and it burns the client's own resources — battery, CPU, radio on a mobile connection — retrying against a target that's given no indication it's ready.

The fix is to make each successive retry wait longer than the last:

function backoffDelay(attempt, { baseDelay = 500, maxDelay = 30000 } = {}) {
  // attempt is 0 on the first retry, 1 on the second, and so on
  const exponential = baseDelay * 2 ** attempt;
  return Math.min(exponential, maxDelay);
}

// attempt 0: 500ms, attempt 1: 1000ms, attempt 2: 2000ms, ...
// capped at maxDelay so it doesn't grow forever

Doubling the delay on each successive failure means a server down for a few seconds only sees a handful of lightly-spaced retries, not continuous hammering, and a server down for minutes sees retries thin out to something the cap keeps sane rather than trailing off into hour-long waits. The cap matters for the opposite reason exponential growth does: without one, a client failing for a long time would eventually wait absurdly long between attempts, well past the point the server has likely recovered.

The fix for that specific failure is jitter: add a small random amount to the computed delay so clients spread out instead of firing in lockstep.

function backoffDelayWithJitter(attempt, opts = {}) {
  const { baseDelay = 500, maxDelay = 30000, jitterRange = 300 } = opts;
  const exponential = Math.min(baseDelay * 2 ** attempt, maxDelay);
  return exponential + Math.random() * jitterRange;
}

Be precise about who jitter is for, because "add some randomness, it's good practice" undersells the reasoning. Jitter does essentially nothing for any single client's own outcome — reconnecting at 500ms versus 500ms plus a random 0-300ms doesn't meaningfully change that client's experience. What jitter changes is the aggregate shape of what 10,000 clients do together: instead of one spike of 10,000 simultaneous attempts, you get those same attempts smeared across an 800ms window — a load profile a recovering server can actually absorb. Jitter fixes a property of the herd, not any one member of it, which is exactly why it's easy to skip in testing — a single developer reconnecting one client locally never sees the problem it exists to prevent.

The transition function, in full

Putting the state machine and the backoff math together, the whole thing that decides "given where we are and what just happened, where do we go next" can be a single pure function, which matters for reasons the next lesson leans on directly:

type ConnectionState = "idle" | "connecting" | "open" | "reconnecting" | "closed";

type ConnectionEvent =
  | { type: "CONNECT" }
  | { type: "OPENED" }
  | { type: "CLOSED_UNEXPECTEDLY" }
  | { type: "CLOSED_DELIBERATELY" }
  | { type: "RETRY_TIMER_FIRED" }
  | { type: "GIVE_UP" };

function transition(state: ConnectionState, event: ConnectionEvent): ConnectionState {
  switch (state) {
    case "idle":
      return event.type === "CONNECT" ? "connecting" : state;
    case "connecting":
      if (event.type === "OPENED") return "open";
      if (event.type === "CLOSED_UNEXPECTEDLY") return "reconnecting";
      return state;
    case "open":
      if (event.type === "CLOSED_UNEXPECTEDLY") return "reconnecting";
      if (event.type === "CLOSED_DELIBERATELY") return "closed";
      return state;
    case "reconnecting":
      if (event.type === "RETRY_TIMER_FIRED") return "connecting";
      if (event.type === "GIVE_UP") return "closed";
      return state;
    case "closed":
      return state; // terminal — no event moves out of closed
  }
}

Every branch matches exactly one row of the transition table above, and any event not explicitly handled for a given state falls through to return state — the machine ignores events that don't make sense from where it currently is, rather than doing something undefined. That's the direct payoff of naming states and events instead of setting booleans: an unhandled combination isn't silently possible anymore, it's a case you can see is missing just by reading the switch.

Where this shows up beyond WebSockets

This isn't specific to WebSockets. Anywhere you catch yourself reaching for a second or third boolean to describe a status that a single existing boolean already partially describes — isLoading plus isRefetching plus hasError, or isSaving plus isDirty plus saveFailed — the same failure mode is available: combinations the shape allows but reality doesn't, and readers left to guess which one is true. Race conditions in async UI code is the same discipline from a different angle — not trusting an implicit assumption (there, about arrival order; here, about which flags are mutually exclusive) and instead making the thing you actually care about an explicit, checkable value.

The transition function above is deliberately a plain, pure function — no useState, no socket, no timers inside it. That's not an accident of this example; it's the whole reason to extract it. A function that takes a state and an event and returns a new state, with nothing else going on, is something you can call directly in a test with no mocking a WebSocket or waiting for a timer required. Testing state-heavy async logic without flakiness picks up exactly here and shows what testing this function in isolation actually looks like.

Go deeper

  • Stately — What are state machines and statecharts? The general theory behind naming states explicitly and constraining transitions, beyond this lesson's hand-rolled example — useful once a connection's states get more complex than this lesson's five.
  • MDN — WebSocket.readyState The browser's own, much coarser version of this idea — four numeric states baked into the WebSocket object itself, worth knowing as the floor this lesson's richer state machine builds above.
  • AWS Architecture Blog — Exponential Backoff And Jitter The original, still-canonical explanation of why jitter matters at the fleet level, not just the single-client level — the thundering-herd argument this lesson makes.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Four booleans — isConnecting, isConnected, isError, isReconnecting — can express sixteen combinations. How many of those correspond to a real connection status, and what happens to the rest?
  2. Explain precisely why 'isConnecting: true and isConnected: true at the same time' is possible with four booleans but not representable with a single ConnectionState variable.
  3. Why does the state machine need two separate edges out of open — one for onclose firing unexpectedly and one for the app calling .close() deliberately — instead of one 'connection ended' edge?
  4. Someone reconnects instantly (no backoff at all) after every dropped connection and says it 'feels more responsive.' What does this cost the server, specifically, that isn't visible from the client's side?
  5. A server restarts and drops 10,000 clients at once. Walk through what happens with exponential backoff but no jitter, and explain why adding jitter fixes it — including why jitter barely changes any single client's own outcome.
  6. In the transition function, what does an event that doesn't match any of the current state's handled cases do, and why is that the correct behavior rather than a bug?
  7. Why is it useful that the transition function takes no socket, no timer, and no dependency on real time as arguments — what does that make possible that wouldn't be possible if backoff timing were computed inside it?