Skip to content

Cloud & Infrastructure · Resilience Patterns

Circuit Breakers Explained: Stopping One Slow Service From Taking Down Everything

A circuit breaker stops your application from hammering a failing dependency until it recovers. Here's how the pattern works, the three states that make it up, and how to implement one without over-engineering a simple retry.

Prathviraj Singh

Prathviraj Singh

6 min read

Circuit Breakers Explained: Stopping One Slow Service From Taking Down Everything

Sponsored

Share

A single slow dependency shouldn’t be able to take down your whole system, but without a circuit breaker, it usually can. Here’s the pattern that stops it, and where it fits alongside timeouts and retries.

The failure mode circuit breakers exist to stop

Picture a checkout service that calls a payment provider’s API. Under normal conditions, calls complete in 200ms. Then the payment provider has an incident: calls now take 30 seconds before timing out, or they fail outright.

Without a circuit breaker, every incoming checkout request still tries to call the payment API, and every one of those calls ties up a thread, a connection pool slot, or an event loop tick for the full 30-second timeout. If your checkout service has a fixed pool of 50 worker threads and requests keep arriving faster than they time out, that pool fills up entirely. New checkout requests, including ones that have nothing to do with payments, start queueing behind requests that are guaranteed to fail. The checkout service now looks down to everything upstream of it, even though the actual failure is one dependency away.

That’s the cascading failure a circuit breaker exists to prevent: contain a downstream failure to the thing that’s actually failing, instead of letting it propagate through every caller in the chain.

The three states

Circuit breaker state machine: closed, open, and half-open

Closed is the default, healthy state. Requests pass through to the dependency normally. The breaker counts recent failures in the background, usually as a rolling percentage over a sliding window of requests, not a raw count.

Open is what happens once failures cross a configured threshold. The breaker stops forwarding calls to the dependency entirely. Instead of waiting out a timeout on every request, calls fail immediately, typically returning a cached value, a sensible default, or an explicit error the caller can handle right away. This is the core value: fast, predictable failure instead of slow, resource-consuming failure.

Half-open is the recovery probe. After a cooldown period, the breaker lets a small number of requests through as a test. If they succeed, the breaker closes and resumes normal traffic. If they fail, it reopens and waits another cooldown before trying again. This prevents two bad outcomes: staying open forever after the dependency has actually recovered, and flooding a barely-recovered dependency with full traffic the moment it shows a sign of life.

A minimal implementation

Most teams shouldn’t hand-roll this in production, but seeing the logic explicitly makes the state machine concrete:

class CircuitBreaker {
  constructor(fn, { failureThreshold = 0.5, windowSize = 20, cooldownMs = 30000 } = {}) {
    this.fn = fn;
    this.failureThreshold = failureThreshold;
    this.windowSize = windowSize;
    this.cooldownMs = cooldownMs;
    this.state = "closed";
    this.results = [];
    this.openedAt = null;
  }

  async call(...args) {
    if (this.state === "open") {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error("Circuit open: failing fast");
      }
      this.state = "half-open";
    }

    try {
      const result = await this.fn(...args);
      this._recordResult(true);
      if (this.state === "half-open") this._close();
      return result;
    } catch (err) {
      this._recordResult(false);
      if (this.state === "half-open") this._open();
      else if (this._failureRate() >= this.failureThreshold) this._open();
      throw err;
    }
  }

  _recordResult(success) {
    this.results.push(success);
    if (this.results.length > this.windowSize) this.results.shift();
  }

  _failureRate() {
    if (this.results.length < this.windowSize) return 0;
    const failures = this.results.filter((r) => !r).length;
    return failures / this.results.length;
  }

  _open() {
    this.state = "open";
    this.openedAt = Date.now();
    this.results = [];
  }

  _close() {
    this.state = "closed";
    this.results = [];
  }
}

This covers the state machine, but production libraries add things this sketch skips: per-endpoint isolation (a breaker per dependency, not one global breaker), metrics and alerting hooks, configurable fallback behavior, and thread-safe counters under concurrent load. That’s the gap between “understands the pattern” and “safe to run in production.”

Circuit breakers, timeouts, and retries are not the same thing

These three patterns get grouped together because they all show up in the same resilience conversation, but they solve distinct problems, and skipping one doesn’t get compensated by the others.

PatternSolvesDoesn’t solve
TimeoutBounds how long you wait for one callRepeated calls to a dead dependency
RetryRecovers from a single transient blipSustained outages: retries alone amplify load on a struggling dependency
Circuit breakerStops sustained failure from cascading upstreamA single flaky call, since it needs a failure pattern to trip

A retry without a circuit breaker against a genuinely down dependency is actively harmful: it multiplies the request volume hitting an already-failing system, which is the opposite of what you want during an incident. The three patterns are complementary. A well-built HTTP client wraps a call in a timeout, adds retry with backoff for transient errors, and wraps the whole thing in a circuit breaker that trips on sustained failure. Skipping the breaker is the most common gap, because timeouts and retries are the ones people reach for first and circuit breakers only prove their value during an incident, when it’s too late to add one.

Where this fits in a real system

Circuit breakers matter most at service boundaries: calls to third-party APIs, calls between your own microservices, and database connections under sustained load. If you’re running a service mesh (Istio, Linkerd), circuit breaking can be applied at the network layer with configuration rather than application code, which is worth evaluating before writing custom logic per service. If you’re not on a mesh, library-level breakers (resilience4j, Polly, opossum, or equivalents in your stack) are the right level of effort for most teams.

This pattern shows up constantly in the kind of production incidents that get post-mortemed after the fact, alongside migration and rollout failures covered in our zero-downtime database migration guide: a dependency degrades, nothing contains the blast radius, and an isolated problem becomes a system-wide one. Adding a circuit breaker to your critical external calls is a small amount of code for a failure mode that’s cheap to prevent and expensive to debug live.

Don’t wait for the incident to add one. Wrap your riskiest external dependency in a circuit breaker this week, watch how often it actually trips, and tune the threshold from what you observe.

Frequently asked questions

What problem does a circuit breaker actually solve?
It stops cascading failure. When a downstream service (a database, an API, a third-party integration) starts failing or responding slowly, every caller that keeps retrying against it ties up its own threads, connections, and queue capacity waiting on a dependency that isn't going to answer in time. That backup can spread upstream through every service in the call chain, turning one slow dependency into an outage across your whole system. A circuit breaker detects the failure pattern and stops sending traffic to the failing dependency, so the failure stays contained to one place instead of spreading.
What are the three states of a circuit breaker?
Closed is the normal state: requests pass through to the dependency, and the breaker counts failures. Open is the tripped state: after failures cross a threshold, the breaker stops sending requests entirely and fails fast, usually returning a cached response, a default value, or an immediate error instead of waiting on a timeout. Half-open is the recovery check: after a cooldown period, the breaker allows a small number of test requests through. If they succeed, it closes again and resumes normal traffic; if they fail, it reopens and waits another cooldown period.
How is a circuit breaker different from a retry or a timeout?
They solve different problems and you typically need all three. A timeout limits how long you wait for a single call before giving up. A retry handles a single transient failure by trying again, usually with backoff. A circuit breaker handles a sustained pattern of failures by stopping calls entirely for a period, which retries alone don't do: a naive retry loop against a genuinely down dependency just multiplies load on a system that's already struggling.
Do I need to build a circuit breaker myself?
Almost never from scratch. Mature libraries exist for most stacks: resilience4j for Java and Kotlin, Polly for .NET, opossum for Node.js, and equivalents in most service mesh implementations (Istio, Linkerd) that apply circuit breaking at the network layer without application code changes. Hand-rolling one is a reasonable learning exercise but a poor production choice, since getting the state transitions and thresholds right under real failure conditions is easy to get subtly wrong.
What should my failure threshold and cooldown actually be?
There's no universal number; it depends on the dependency's normal failure rate and how expensive a false trip is. A common starting point is tripping after 50% of requests fail within a rolling window of 10-20 requests, with a cooldown of 30-60 seconds before testing recovery. Start conservative, watch how often the breaker actually trips in production, and tune from real data rather than guessing up front.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored