Circuit Breaker
Stop hammering a service that's already down. Give it room to recover.
The simple version
If a downstream service starts failing, calling it over and over — hoping this time it works — usually makes things worse, not better. A circuit breaker watches for repeated failures and, past a threshold, stops calling entirely for a while, failing fast instead.
The analogy
It’s called a circuit breaker because it’s the literal electrical one, and the metaphor is almost too on-the-nose.
When a house circuit detects a dangerous surge, it doesn’t let current keep flowing and hope for the best — it trips, cutting the circuit immediately. That protects the wiring from actual damage. Crucially, it doesn’t stay tripped forever out of panic: after a while, someone flips it back — or in the electrical version, a fault-clearing mechanism allows a cautious retry — to see if the underlying problem is gone.
Software does the same thing on purpose: if a service downstream keeps failing, stop sending it traffic, protect your own system from piling up broken requests, then cautiously test the waters again later.
The bridge
A circuit breaker sits in front of a risky call and tracks its own state:
- Closed — normal operation. Calls go through. Failures are counted.
- Open — too many recent failures tripped the breaker. Calls fail immediately, without even attempting the network call, for a cooldown period.
- Half-open — after the cooldown, let a small number of calls through as a test. If they succeed, close the breaker (back to normal). If they fail, snap back to open and wait again.
The value isn’t just “fewer failed calls” — it’s protecting the caller. Without a breaker, a slow or dead downstream service can pile up threads/connections waiting on timeouts until the caller itself falls over — a failure in one service cascading into a failure of the whole system. Failing fast (no network call at all, while open) keeps the caller healthy even while the dependency is down.
In code
class CircuitBreaker {
private failures = 0;
private state: "closed" | "open" | "half-open" = "closed";
private openedAt = 0;
constructor(
private readonly threshold = 5,
private readonly cooldownMs = 30_000
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new Error("Circuit open — failing fast, not calling downstream");
}
this.state = "half-open"; // cooldown elapsed, cautiously testing again
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failures = 0;
this.state = "closed";
}
private onFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
}
const paymentBreaker = new CircuitBreaker();
async function chargeCard(order: Order) {
return paymentBreaker.call(() => paymentGateway.charge(order));
}
Once paymentGateway fails 5 times in a row, further calls fail instantly with “circuit open” for 30 seconds — no wasted timeouts, no pile-up — then the breaker lets one call through to check if the gateway has recovered.
When you don’t need it
For calls that are cheap, fast to time out, and non-critical, a circuit breaker is just extra state to maintain for little benefit — a short timeout and a retry might be all you need. It earns its place around calls to external dependencies that are slow to fail (long timeouts), expensive to retry, or where a pile-up of failed calls could genuinely take your own service down with it.