Skip to content
← Back to all lessons
Day 035 System Design

Circuit Breaker - Fail Fast on a Dead Dependency

CLOSED to OPEN to HALF-OPEN: stop paying the timeout tax. Lab (fake clock): 10,200 ms to 450 ms (~22.67x wall), 200 to 5 dep calls (~40x). Pool-fill blast radius craft.

9 min read

This video presents visual lesson highlights with instrumental background music. The complete lesson is available as text below.

Day 9 caps client traffic. Day 30 makes retries safe. Today: when the dependency is on fire, stop feeding it timeouts — open the circuit and fail fast.

Your payment API calls a fraud service. The fraud service is down. Every request waits 50–2000 ms, then fails. Thread pools fill. Your healthy services look dead because they are blocked on a corpse. A circuit breaker watches failures: after enough consecutive failures it opens — further calls short-circuit immediately with an error (or fallback) instead of paying the timeout tax. After a cooldown it tries one probe (half-open). Success closes the circuit; failure opens it again.

The problem: timeout cascades

// Every call waits for the full timeout
for (const req of traffic) {
  await fraudService.check(req); // 50ms hang × N = meltdown
}

Without a breaker: 200 calls against a dead dependency = 200 timeouts. Callers, queues, and thread pools all absorb the cost. Rate limiting (Day 9) protects against clients; the breaker protects against a dependency.

Worked example: payment + fraud pool meltdown

Imagine a worker pool of 20 threads handling checkout. Each checkout needs fraud. Fraud is dead and times out at 50 ms in our lab model (real systems often use 1–2 s — same shape, worse numbers).

  1. Requests 1–5 each grab a thread, wait 50 ms, fail. Failure counter climbs.
  2. Without a breaker, requests 6–200 keep grabbing threads and waiting. The pool never free for “list cart” or “apply coupon.”
  3. With a breaker (threshold 5): after five timeouts the circuit opens. Requests 6–200 fail in ~0 ms (short-circuit). Threads stay free. Fraud still needs fixing — but your API stays responsive.

That is the product claim: protect the rest of the system, not “heal fraud.”

The state machine

  • Closed — normal. Failures count toward a threshold.
  • Open — short-circuit. Do not call the dependency. Fail fast (or use a fallback).
  • Half-open — after a cooldown, allow a probe. Success → closed. Failure → open again.
// Sketch (threshold = 5). epoch changes on every state transition.
let state = "closed";
let failures = 0;
let openedAt = 0;
let epoch = 0;
let probeInFlight = false;

function transition(nextState) {
  state = nextState;
  epoch++;
  probeInFlight = false;
}

function openBreaker() {
  openedAt = Date.now();
  transition("open");
}

async function guardedCall() {
  if (state === "open") {
    if (Date.now() - openedAt < cooldown) return failFast();
    transition("half_open");
  }

  // Assign the half-open permit synchronously, before the first await.
  const isProbe = state === "half_open";
  if (isProbe && probeInFlight) return failFast();
  if (isProbe) probeInFlight = true;
  const admittedEpoch = epoch;

  try {
    const result = await dependency();
    if (!result.ok) throw new Error("dependency failed");

    // A call admitted before a newer transition may return to its caller,
    // but its stale outcome must not mutate the newer breaker generation.
    if (admittedEpoch !== epoch) return result;

    failures = 0;
    if (isProbe) transition("closed"); // only the authorized probe can close
    return result;
  } catch (error) {
    if (admittedEpoch !== epoch) throw error; // ignore stale state mutation

    if (isProbe) {
      openBreaker(); // failed probe starts a fresh cooldown generation
    } else {
      failures++;
      if (failures >= 5) openBreaker();
    }
    throw error;
  } finally {
    // Do not clear a permit that belongs to a newer generation.
    if (isProbe && admittedEpoch === epoch) probeInFlight = false;
  }
}
CLOSEDcalls passN failsOPENfail fastcooldownHALF-OPENone probeprobe OK → CLOSEDprobe fail

Fallback: what “fail fast” returns

Open does not mean “throw and hope.” Product design chooses the open-path response:

CallSafe open fallback?Why
Product catalog readOften yes — stale cache / “unavailable” bannerRead can degrade
Payment authorizeUsually no silent successMust not invent money movement
Fraud checkPolicy call — block vs queue for reviewRisk appetite, not “always 200”

Interview answer: breaker protects capacity; fallback protects product truth.

What it is not (and neighbors)

ToolAxisReacts toJob
Rate limit (Day 9)Client → youVolumeCap traffic
Circuit breaker (today)You → dependencyFailures / timeoutsStop feeding a corpse
Bulkhead (Day 42)Pool isolationSlow neighborSeal compartments
Retry + jitter (Day 37)TimeTransient errorsTry again safely
Idempotency (Day 30)At-least-onceDuplicate attemptsNo double charge
  • Not a retry policy — retries may still need a breaker so you do not retry into a black hole.
  • Not a load balancer health check alone — LB removes bad nodes; a breaker protects a logical dependency even when DNS still resolves.
  • Not a fix for the dependency — it buys time and protects the rest of the system while you fix the root cause.

Half-open: one probe, not a herd

When the cooldown ends, only a small number of calls (often one) should probe. If half-open lets the whole backlog through, you recreate the cascade on a recovering service. Implementations use a probe permit, single-flight, or a tiny concurrency limit in half-open.

Benchmark — simulated dead dependency (Node, 30-run median)

Model: success = 1 ms; failure = 50 ms timeout tax. 200 sequential calls. Open after 5 consecutive failures; half-open after 200 ms. Fake clock — ratios and call counts are the product claim. The standalone result artifact is not included in this repository.

ModeMedian wall msDep calls (first run)
No breaker (always down)10,200200 (all timeout)
With breaker (always down)4505 timeouts + 195 short-circuits
Healthy + breaker400200 successes, stays closed

Wall time ~22.67× without vs with breaker on a dead dependency. ~40× fewer dependency calls (200 → 5). That is the cascade you avoid. Absolute ms are modelled — do not cite them as production RTT.

What breaks?

  • Threshold too low — flaky blips open the circuit and amplify outages.
  • Threshold too high / no breaker — full timeout cascade (the lab’s 10,200 ms path).
  • No half-open probe — stays open forever after a brief blip; need recovery path.
  • Half-open herd — all waiters probe at once; recovering dep dies again.
  • Shared global breaker for all tenants — one noisy neighbor opens for everyone; scope carefully.
  • Silent success fallbacks on money paths — fail-fast must not invent authorizations.
  • Confusing with rate limit — rate limit caps volume; breaker reacts to failure.

How it connects

  • Day 9 (Rate limiting): protect from clients. Breaker: protect from dependencies.
  • Day 7 (Load balancing): health checks remove nodes; breaker is in-process / per-client policy.
  • Day 14 (Queues): consumers should fail fast on dead deps so they do not hold messages forever.
  • Day 16 (API Gateway): gateway is a natural place to wrap upstreams with breakers.
  • Day 30 (Idempotency): retries need keys; retries into a dead service still need a breaker.
  • Day 34 (Deferred constraints): different domain — DB integrity timing — but same idea: finish line vs mid-path pain.
  • Day 42 (Bulkhead): breaker stops calling a dead dep; bulkhead stops a slow dep from eating the whole pool.

Transfer question 1 You wrap a database with a circuit breaker. On open, what fallback is safe for a payment authorize vs a product catalog read?

Transfer question 2 Half-open allows one probe. Two concurrent requests arrive while half-open. How do you prevent a thundering herd of probes?

Transfer question 3 Day 23 replication lag makes a replica “slow but not dead.” Should a breaker open on latency SLO breach, error rate, or both?

Quiz

1. What does an OPEN circuit breaker do on the next call?

2. How is a circuit breaker different from a rate limiter?

3. On our always-down lab (200 calls, 30-run median), the breaker:

Your turn — the teach step Close this lesson. Write the “Explain like I’m 10” and the 60-second LinkedIn version from memory. Focus on: closed/open/half-open, timeout cascades, fail-fast ≠ fix, fallback truth, and the ~23× / 40× lab. Post it, and paste the link.