Circuit Breakers
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?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
one charge · teal = closed, amber = half-open, rust = open or failing
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 neededWithout 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?
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?
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?
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?
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.