
Published on 11 min read

Photo by Mostafa Mahmoudi on Unsplash
A dependency that is already failing does not need more traffic. Timeouts free your threads. Retries give transient blips a second chance. Neither stops a stampede of callers from hammering a service that will not recover for minutes. That is the job of a circuit breaker: fail fast locally, give the dependency room to heal, then probe carefully before restoring full traffic.
Michael Nygard popularized the pattern in Release It! (2007; 2nd ed. 2018). Martin Fowler’s widely cited write-up and Microsoft’s Azure Architecture Center docs formalized the three-state machine most libraries still ship today. This article is a practical reference: how the states work, how to tune failure detection, how to combine breakers with retries, and when Amazon’s builders prefer a retry quota instead.
A circuit breaker is a proxy around a remote call (or any operation that can fail). It watches recent outcomes. When failures cross a threshold, it trips: further calls return an error (or a fallback) without invoking the dependency. After a cool-down, it lets a limited number of probe requests through. Success closes the circuit; failure opens it again.
The electrical metaphor is deliberate. An electrical breaker opens under fault current so wiring and equipment are not destroyed. A software breaker opens under fault rate (or consecutive failures) so your thread pools, connection pools, and upstream callers are not destroyed by waiting on doomed work.
It complements — it does not replace — timeouts, retries, jitter, and backoff:
| Concern | Tool |
|---|---|
| Cap how long one call waits | Timeout / deadline |
| Hide short, rare blips | Retry with backoff + jitter |
| Cap retry amplification under outage | Retry budget / token bucket |
| Stop calling when the dependency looks unhealthy | Circuit breaker |
| Isolate failure domains (separate pools) | Bulkhead |
Microsoft’s Cloud Design Patterns description matches the machine almost every library implements.
Normal operation. Requests go to the dependency. The breaker records successes and failures in a window (count-based or time-based). Occasional errors stay in Closed. When the failure signal crosses the configured threshold and enough calls have been observed, the breaker transitions to Open.
Fast fail. Calls do not reach the dependency. Callers get an immediate error (or a configured fallback: cached data, default, queue-for-later). A wait / reset / break duration timer starts. When it expires, the breaker moves to Half-Open — not straight back to Closed.
Open is the point of the pattern: you stop spending latency and concurrency on work that is likely to fail, and you stop adding load to a struggling service.
Cautious recovery. A limited number of probe requests are allowed. If they succeed (by count or by failure-rate rules), the breaker returns to Closed and resets failure metrics. If a probe fails, it returns to Open and the cool-down starts again.
Half-Open exists because you cannot know recovery without trying — but flooding a half-recovered service with full traffic can knock it over again. Microsoft calls this out explicitly: a recovering dependency may only handle limited volume until it is healthy.
failures ≥ threshold
Closed ───────────────────► Open
▲ │
│ │ wait duration expires
│ probes succeed ▼
└─────────────── Half-Open
│
│ probe fails
└──► OpenNot every error should trip the breaker. Fowler notes that some failures are normal business outcomes and should not open the circuit. Typical production rules:
5xx, explicit “dependency unavailable”4xx validation / auth (caller fault), expected “not found”429 / 503 with Retry-After — Microsoft describes accelerated breaking: trip immediately and stay open at least as long as the dependency asked you to waitLibraries also treat slow calls as a separate signal. Resilience4j can open the circuit when the slow-call rate exceeds a threshold (calls slower than slowCallDurationThreshold), even if they eventually succeed — useful when latency, not hard errors, is what exhausts your concurrency.
Toy examples trip after N consecutive failures. Production breakers usually use a sliding window and a failure rate.
Resilience4j defaults (widely used as a mental model):
| Setting | Default | Role |
|---|---|---|
slidingWindowType | COUNT_BASED | Last N calls, or last N seconds if time-based |
slidingWindowSize | 100 | Window width |
minimumNumberOfCalls | 100 | Do not evaluate rate until this many outcomes exist |
failureRateThreshold | 50 | Open when failure % ≥ this |
waitDurationInOpenState | 60s | Cool-down before Half-Open |
permittedNumberOfCallsInHalfOpenState | 10 | Probe budget |
minimumNumberOfCalls prevents a cold process from opening after one or two unlucky errors. Polly’s circuit breaker strategy uses the same idea: failure ratio over a sampling duration, with minimum throughput before the ratio is trusted (example from Polly docs: 50% failures in any 10-second window, at least 8 actions, then break for 30 seconds).
Opossum (Node.js) exposes the same knobs with different names: errorThresholdPercentage, volumeThreshold, resetTimeout, plus an optional per-call timeout.
Without a breaker, a slow or dead dependency turns into resource exhaustion on the caller: threads blocked on timeouts, connection pools stuck, queue depth rising, then your callers time out and retry. Google’s SRE book on cascading failures frames the core risk as positive feedback — overload on one replica increases load on the rest until the whole tier fails. Client-side protection (failing fast, shedding work, not retrying forever) is how you cut that loop.
Breakers also improve user-visible latency. An open circuit returns in microseconds instead of waiting for a multi-second timeout. Azure’s docs stress that this protects response times and gives operations a clear signal: state transitions are excellent health events.
Use a circuit breaker when:
Prefer something else when:
AWS Prescriptive Guidance still recommends the pattern when a callee is down or high latency would otherwise cause retry pile-ups, network contention, and thread-pool burn.
Advantages
Trade-offs
A minimal TypeScript breaker matching the classic three-state model. Prefer a battle-tested library in production (opossum on Node, Polly on .NET, Resilience4j on the JVM); this sketch shows the control flow.
type State = "closed" | "open" | "half_open";
type Options = {
failureThreshold: number; // consecutive failures to open (simple detector)
resetTimeoutMs: number; // open → half-open cool-down
successThreshold: number; // half-open successes to close
};
class CircuitBreaker {
private state: State = "closed";
private failures = 0;
private successes = 0;
private openedAt = 0;
constructor(private readonly options: Options) {}
async exec<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt >= this.options.resetTimeoutMs) {
this.state = "half_open";
this.successes = 0;
} else {
throw new Error("CircuitOpen");
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
if (this.state === "half_open") {
this.successes += 1;
if (this.successes >= this.options.successThreshold) {
this.state = "closed";
this.failures = 0;
}
return;
}
this.failures = 0;
}
private onFailure() {
this.failures += 1;
if (
this.state === "half_open" ||
this.failures >= this.options.failureThreshold
) {
this.state = "open";
this.openedAt = Date.now();
this.successes = 0;
}
}
}
const payment = new CircuitBreaker({
failureThreshold: 5,
resetTimeoutMs: 30_000,
successThreshold: 2,
});
// Pair with a per-call timeout in real code
await payment.exec(() => chargeCard(orderId));With opossum, the same idea is configuration rather than a hand-rolled state machine:
import CircuitBreaker from "opossum";
const breaker = new CircuitBreaker(chargeCard, {
timeout: 3_000,
errorThresholdPercentage: 50,
resetTimeout: 30_000,
volumeThreshold: 10,
});
breaker.fallback(() => ({ status: "queued" })); // degrade while open
breaker.on("open", () => console.warn("payment circuit open"));
await breaker.fire(orderId);Microsoft’s rule is the one to remember: retry through a breaker, but stop retrying when the breaker says the fault is not transient (open / broken-circuit exceptions).
A sane pipeline:
Do not retry open-circuit errors with the same aggressiveness as a single 503. That recreates the stampede the breaker just stopped.
400 / 401)CircuitBreakerManualControl and Resilience4j’s forced states exist so ops can drain traffic during incidents| Approach | What it does | Relative to a breaker |
|---|---|---|
| Retry budget / token bucket | Caps retries process-wide | Simpler modal surface; Amazon often prefers this for retry amplification |
| Adaptive client throttling (Google SRE) | Probabilistically sheds when rejects rise | Continuous throttle vs binary Open/Closed |
| Bulkhead | Separate pools / concurrency limits per dependency | Isolates blast radius; often paired with breakers |
| Load shedding | Server refuses excess work | Protects the callee; breaker protects the caller |
| Service mesh / gateway breakers | Policy outside app code | Centralized, but still needs correct domains and metrics |
| Hedged requests | Speculative parallel attempt | Different problem (tail latency); can increase load |
Use breakers when you need a hard stop and an explicit recovery handshake. Use budgets and shedding when you mainly need to bound amplification without a sharp mode change.
No. A timeout bounds one call. A breaker decides whether to attempt the call at all based on recent health.
No. Start with deadlines, idempotent retries, jitter, and a retry budget. Add a breaker where sustained dependency failure would cascade or pin resources — typically critical synchronous paths.
Anywhere you can observe outcomes and fail fast. In-process libraries (opossum, Polly, Resilience4j) are easy to reason about per service. Meshes and gateways help when many languages share one policy plane. Distributed shared state (e.g. DynamoDB-backed status in AWS samples) helps when many compute instances must share one view of “payment is down.”
Handle it like a known outage: degrade, queue work, show a clear UI, or fail the user journey quickly. Do not spin on retries against CircuitOpen / BrokenCircuitException.
Start from dependency SLOs and recovery times. If p99 latency and error budgets say the service is unhealthy above ~X% errors for Y seconds, align the window and threshold nearby — then validate under load. Prefer conservative minimum throughput so sparse traffic cannot flap the breaker.
A circuit breaker is how you stop calling a dependency that has stopped being useful. Closed monitors, Open protects, Half-Open verifies. Pair it with timeouts and careful retries; respect open-circuit errors; isolate failure domains; and measure state transitions like any other production control plane.
If you only need to tame retry storms, try a retry budget first. If you need a hard stop and graceful degradation while something is dark for minutes, install a breaker — then test the Open and Half-Open paths as carefully as the happy path.
IHttpClientFactory)