08 · Scaling & Distributed Systems

Circuit Breakers

8 min read · 5 questions · 1 exercise

A circuit breaker stops calling a dependency that keeps failing, so callers get an answer at once instead of waiting, and lets one trial call decide when to start again.

It is the evening of a sale, and the payments provider has started to hang. It does not refuse connections or return errors; it accepts each charge and says nothing until checkout’s HTTP client gives up thirty seconds later. Checkout keeps calling, because nothing in checkout knows better. Within seconds every worker thread is sitting in a call to payments, and then the product pages, the cart and the order history start timing out too, although none of them touch payments, because no thread is left to serve them.

Nothing was wrong with checkout. It was taken down by waiting politely for an answer that was never coming. What it needed was a way to notice that payments was failing and stop asking for a while: to give up in a millisecond instead of thirty seconds, and to check back later. That is a circuit breaker.

How one slow service spreads

A dependency that fails quickly is cheap to live with. The one that hurts is the slow one, because every call to it holds something in the caller for as long as it waits: a thread, a connection from a pool, a socket, the memory for the request. How many are held at once is simple arithmetic: the rate calls arrive at, multiplied by how long each one waits. That is Little’s law.

Put illustrative numbers on it. Checkout handles 50 requests a second, and each one charges a card. Payments normally answers in 80 ms, so about 50 × 0.08 = 4 calls are in flight at any moment, and a pool of 200 threads barely notices them. Now payments hangs and the timeout is 30 seconds. Calls in flight become 50 × 30 = 1,500, and the pool of 200 is full after 200 ÷ 50 = 4 seconds. From then on checkout cannot accept anything, including requests that never go near payments.

Retries make it worse. If each charge is attempted three times before checkout gives up, payments receives 150 calls a second instead of 50, just as it is struggling, and each checkout request holds its thread for three timeouts instead of one. Even with a 2-second timeout, that is 6 seconds a request and 50 × 6 = 300 threads wanted from a pool of 200. A failure in one service has become an outage in its caller, and the caller’s callers are next. That is a cascading failure.

A shorter timeout is necessary but not sufficient. At 2 seconds without retries checkout holds 50 × 2 = 100 threads and survives, but every customer still waits two seconds for an error the previous hundred already got. The evidence that payments is down is in the caller’s own recent history, and nothing uses it.

Closed, open, half-open

A circuit breaker is a small object that wraps every call to one dependency and remembers how those calls have been going. The name comes from the switch in a fuse box that cuts the power when too much current flows. It also explains the detail that trips people up: in a circuit, closed means current flows and open means it does not. An open breaker is the one refusing calls.

There are three states. Closed is normal: every call goes through, and the breaker counts failures, meaning timeouts, refused connections and server errors. A success resets the count. When the count reaches a threshold, say 3 failures in a row, the breaker trips open. While open it does not call the dependency at all, and every call fails immediately, in the time it takes to read a field. That is failing fast, and it does two jobs: callers stop waiting, and a struggling dependency stops receiving traffic it cannot handle, which is often what it needs to recover.

An open breaker cannot tell when the dependency is healthy again, because it has stopped asking. So after a cool-down, say 10 seconds, it moves to half-open and lets exactly one call through as a trial. If the trial succeeds, the breaker closes and traffic resumes; if it fails, the breaker opens again for another full cool-down. Every other call that arrives while the trial is out still fails fast. One request decides, so a dependency that is still down receives one call per cool-down rather than a flood. A load balancer’s health check asks the same question with a probe on a timer; a breaker asks with real calls, from inside the caller.

Predict first

Payments hangs from the second charge on and recovers between the breaker's first trial and its second. The breaker opens after 3 timeouts in a row, then sends one trial charge after each 10 s cool-down, and every timeout takes 2 s. Of the scene's 10 charges, how many wait out a full timeout?

  1. That is every charge sent while payments is down, which is what happens with no breaker at all. Once the breaker is open, charges 5, 6 and 8 are refused before they reach payments, so they never wait.

  2. That treats the end of the cool-down as closing the breaker, so it would need 3 fresh timeouts to open again, and charges 7 and 8 would both wait. Half-open is not closed: one trial goes through, and one failure sends the breaker straight back to open.

  3. Three in a row to trip it, charges 2, 3 and 4, plus the first trial, charge 7, which is a real charge and hangs like the others. The second trial finds payments healthy and closes the breaker. Once the breaker is open, the only charges that wait are the trials.

  4. Only the three that trip it would wait if nothing ever went through an open breaker again. Half-open sends a real charge as its trial, and while payments is still down that charge waits out its 2 s like any other.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Closed, open, half-open, closed

one charge · teal = closed, amber = half-open, rust = open or failing

checkout
breaker
payments
fallback

A closed breaker, and a payments service about to fail — use Next step to go one at a time.

The payments badge counts the calls that reached it and the fallback’s counts the calls answered without it, so the first stops climbing while the breaker is rust. The scene sends one charge at a time. At 50 a second, every charge sent before the third timeout fires also waits its 2 seconds, about a hundred of them: a breaker limits the damage from the moment it trips, not before.

The whole state machine fits in one class. The clock is a field, so a test can move time forward instead of waiting for it:

class CircuitBreaker {
  state: 'closed' | 'open' | 'half-open' = 'closed';
  threshold = 3; // failures in a row that trip it open
  coolDownMs = 10_000; // how long it stays open before one trial call
  now = Date.now; // the clock, replaceable so a test need not wait
  private failures = 0;
  private openUntil = 0;

  async call<T>(request: () => Promise<T>): Promise<T> {
    if (this.state === 'open' && this.now() >= this.openUntil) {
      this.state = 'half-open'; // this call is the trial; others fail fast
    } else if (this.state !== 'closed') {
      throw new Error('circuit open: failing fast');
    }
    try {
      const result = await request();
      this.state = 'closed';
      this.failures = 0;
      return result;
    } catch (error) {
      this.failures += 1;
      if (this.state === 'half-open' || this.failures >= this.threshold) {
        this.state = 'open';
        this.openUntil = this.now() + this.coolDownMs;
      }
      throw error;
    }
  }
}

Notice what call cannot see. It learns about a failure only when request rejects, and a request that hangs never rejects, so around a client with no timeout this breaker would stay closed through the whole outage in the opening. The timeout is not the breaker’s job, but the breaker is blind without one. Checkout calls breaker.call(() => payments.charge(order)) and catches the error to choose what to return instead.

Try it

Give the breaker a failure to count

call counts a failure only when request rejects, and a payments call that hangs never does. Write withTimeout(request, ms), which returns a new request that settles however request settles, or rejects with an error once ms milliseconds pass with no answer. Three calls through a fresh breaker, each wrapping a charge that never answers, should leave the breaker open.

Hint

Build the returned promise by hand with new Promise, and start a setTimeout that calls reject. A promise settles only once, so whichever comes first, the answer or the timer, wins and the other is ignored. Then ask what the timer should do when the answer wins.

Solution
function withTimeout<T>(
  request: () => Promise<T>,
  ms: number,
): () => Promise<T> {
  return () =>
    new Promise<T>((resolve, reject) => {
      const timer = setTimeout(
        () => reject(new Error(`no answer after ${ms} ms`)),
        ms,
      );
      request()
        .then(resolve, reject)
        .finally(() => clearTimeout(timer));
    });
}

The clearTimeout is the line that is easy to leave out. Without it, every call that is answered in time still leaves its timer pending for the full ms, one per call. And the timeout ends the waiting, not the work: the hung request is still open underneath, holding its socket. To cancel it for real, pass an AbortSignal to the HTTP client and abort it when the timer fires.

To see it work, point a breaker at a charge that never answers:

const breaker = new CircuitBreaker();
const hang = (): Promise<string> => new Promise(() => {}); // never settles

for (let i = 0; i < 3; i += 1) {
  await breaker.call(withTimeout(hang, 50)).catch((error: Error) => {
    console.log(error.message); // no answer after 50 ms (three times)
  });
}
console.log(breaker.state); // open

await breaker.call(hang).catch((error: Error) => console.log(error.message));
// circuit open: failing fast — at once, with no timeout needed

Without withTimeout, the first await never returns, and the breaker stays closed for as long as anyone cares to wait.

What to return instead

Failing fast only helps if the caller has something better to return, and choosing it is a product decision. There are three honest kinds of answer. The first degrades the feature: accept the order, mark the payment pending and settle it from a queue once payments is back, provided the business can live with the occasional card that declines an hour later. The second is a cached value, such as the last known exchange rate, good enough for a page that is checked again before money moves. The third is a clear error, returned quickly. Where no substitute is safe, because you cannot pretend a card was charged, a fast and honest error still beats the same error thirty seconds later.

Keep the fallback cheap and away from the failing dependency. It runs on every call while the breaker is open, which is exactly when the system is most stressed.

Choosing the numbers, and the limits

Three numbers need choosing, and each can be wrong in both directions. The timeout should come from measured latency, not from a client library’s default: a little above the slowest answer you still consider healthy, such as the 99th percentile, and short enough that rate × timeout fits comfortably in the pool. The threshold should not trip on noise. Consecutive failures is the simplest rule; many implementations use a failure rate over a sliding window instead, with a minimum number of calls, so that two failures out of three at 4 a.m. do not read as a 67% failure rate. The cool-down trades two harms: too short and trials keep hitting a service that is still down; too long and a healthy one is refused for no reason.

Four limits are worth knowing. Keep one breaker per dependency: a single breaker around everything lets one failing service shut off all the healthy ones. Its state is per instance: each process keeps its own count, so twenty instances of checkout each discover the outage for themselves and each send their own trials. It is not a retry policy: a breaker never retries, it refuses. Retry, if at all, outside it and with a growing delay, so that each attempt is counted and an open breaker refuses the retries too. And it can flap: a dependency that fails about half its calls can trip, pass one lucky trial, close, and trip again. A rate-based threshold, and a cool-down that grows after each failed trial, both damp it.

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

Checkout wraps every outgoing call, to payments and to the email service alike, in one shared breaker. The email service starts timing out. What happens to card payments?

  1. The breaker counts failures through it, not per destination. Email timeouts trip it, and an open breaker refuses everything it wraps, payments included.

  2. One breaker means one count and one state for both services, so an email outage becomes a payments outage. Give each dependency its own breaker, and a dependency whose endpoints fail independently one per endpoint.

  3. A breaker never retries; it either lets a call through or refuses it. The slowdown here is the email timeouts themselves, until the shared breaker opens and refuses payments as well.

  4. Nothing here queues. An open breaker answers at once with an error, so charges fail immediately rather than wait, and that is the problem, since payments was never down.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Checkout makes 40 calls a second to a recommendations service, through a breaker that opens after 10 failures in a row. The timeout is 5 s and checkout has 150 worker threads. Recommendations starts to hang. When does checkout run out of threads?

  1. The breaker counts a failure only when a timeout fires, and the first one fires 5 s after the hang began. By then 40 × 5 = 200 calls have started, more than the 150 threads.

  2. That counts a call as failed the moment it is sent. A hung call is not a failure until its timeout fires, so nothing has failed at 0.25 s; the threads are simply all still waiting.

  3. Timeouts free threads, they do not take them. The threads were taken by calls that are still waiting, and they run out before the first timeout fires.

  4. 150 threads ÷ 40 calls a second = 3.75 s, and the first timeout fires at 5 s. The breaker is blind until a timeout fires, so the timeout has to be short enough that rate × timeout fits in the pool: under 3.75 s here, and in practice well under.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

Checkout runs as 20 instances, and each one creates a CircuitBreaker for payments when it starts, with a 30 s cool-down. Payments goes down for ten minutes, and every breaker trips within a second of the others. About how many charges reach payments in each 30 s while it is down?

  1. Each process has its own breaker object, with its own count and its own clock, and none of them knows what the others have seen. Twenty breakers on the same schedule send twenty trials together. That is usually harmless; across hundreds of instances, random jitter on the cool-down spreads them out.

  2. One trial per breaker, but there are twenty breakers. Nothing shares their state between processes unless you build that, and most deployments do not.

  3. A breaker does not listen for health reports. It learns only from real calls, which is why half-open sends a real charge as its trial.

  4. Half-open lets exactly one call through and refuses the rest until that call settles. Traffic comes back only after a trial succeeds and the breaker closes.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

A flash sale brings a wave of declined cards. Payments answers each one in 50 ms with 402 card declined, and the breaker trips open, refusing every checkout, including the cards that would have gone through. What should change?

  1. That only delays the same mistake. A big enough wave of declines still trips it, and a real outage now takes longer to detect.

  2. A decline is payments working correctly and answering quickly. Count timeouts, refused connections and server errors, and let a 4xx business answer pass through as a result. The breaker exists to detect a sick dependency, not unlucky customers.

  3. The next trial may well succeed, but the declines keep coming, so the breaker trips again moments later. A shorter cool-down turns one wrong decision into a breaker that flaps.

  4. A declined card declines again. Retrying doubles the calls and the failures, and the breaker trips twice as fast.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Key takeaways

  • A slow dependency is more dangerous than a dead one: every caller holds a thread or connection for as long as it waits, and retries multiply the wait.

  • A circuit breaker counts failures to one dependency, trips open to fail calls at once, and after a cool-down lets one trial decide whether to close.

  • Failing fast helps only when the caller has a degraded answer, a cached value or a clear error to return instead.

  • A breaker is blind without a timeout, keeps its state per instance, guards one dependency, and refuses rather than retries.