Back to blog
Feb 2026·3 min readAI-generated

The Circuit Breaker Pattern for Resilient Services

#backend#reliability#distributed-systems

When a downstream service starts failing, the naive response — keep retrying every request as it comes in — is exactly the wrong move. Every retry is another request piling onto a service that's already struggling, and every caller blocked waiting for a timeout is a thread or connection your own service can't use for anything else. The circuit breaker pattern exists to stop this: after enough failures, it stops sending requests entirely for a while, failing fast instead of failing slow.

The three states

A circuit breaker is a state machine with three states, named after the electrical analogy:

  • Closed — normal operation. Requests pass through to the downstream service. Failures are counted.
  • Open — the failure threshold was crossed. Requests fail immediately, without even attempting the call, for a cooldown period.
  • Half-open — after the cooldown, one (or a few) test requests are let through. If they succeed, the breaker closes again. If they fail, it reopens and the cooldown starts over.
CLOSED --(too many failures)--> OPEN --(cooldown elapses)--> HALF_OPEN
   ^                                                              |
   |______________________(test request succeeds)_________________|
                            (test request fails) -----> OPEN

The key insight is in the "open" state: instead of every caller independently waiting out a timeout against a service that's down, the breaker fails immediately and cheaply, freeing up resources on your side and giving the downstream service room to recover without more load piling on.

A minimal implementation

class CircuitBreaker {
  constructor(fn, { failureThreshold = 5, cooldownMs = 30_000 } = {}) {
    this.fn = fn;
    this.failureThreshold = failureThreshold;
    this.cooldownMs = cooldownMs;
    this.state = 'CLOSED';
    this.failureCount = 0;
    this.openedAt = null;
  }

  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.openedAt >= this.cooldownMs) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit is open — failing fast');
      }
    }

    try {
      const result = await this.fn(...args);
      this.#onSuccess();
      return result;
    } catch (err) {
      this.#onFailure();
      throw err;
    }
  }

  #onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  #onFailure() {
    this.failureCount++;
    if (this.state === 'HALF_OPEN' || this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.openedAt = Date.now();
    }
  }
}

Usage — wrap any function that makes a risky call:

const breaker = new CircuitBreaker(
  (orderId) => fetch(`https://payments.internal/charge/${orderId}`).then((r) => r.json()),
  { failureThreshold: 5, cooldownMs: 30_000 },
);

try {
  const result = await breaker.call(order.id);
} catch (err) {
  // Either the payment call actually failed, or the breaker is open —
  // either way, don't retry synchronously in the same request.
  return res.status(503).json({ error: 'Payment service unavailable, try again shortly' });
}

Note that a single failed call in HALF_OPEN re-opens the breaker immediately rather than requiring the full threshold again — the whole point of the half-open probe is caution, so one bad test result should be enough to conclude the service isn't ready yet.

Circuit breaker vs. retry vs. timeout

These three are often confused because they all respond to "the downstream call isn't working," but they solve different problems:

  • Timeout bounds how long you wait for a single call before giving up.
  • Retry re-attempts a single failed call, usually with backoff, on the assumption that the failure was transient.
  • Circuit breaker tracks failures across calls over time, and stops attempting calls altogether once a service looks systemically broken rather than transiently unlucky.

They compose: a call is typically wrapped in a timeout, retried a bounded number of times on failure, and the retry logic itself sits behind a circuit breaker so that a systemically down service doesn't get hammered with (timeout + retries) × every incoming request.

What the failure threshold should count

Counting raw failures over unbounded time is usually wrong — five failures out of five requests this minute is a real problem; five failures out of ten thousand requests over a week is noise. A sliding window (count failures in the last N requests, or the last N seconds) is the practical choice:

class SlidingWindowBreaker {
  constructor(fn, { failureRateThreshold = 0.5, windowSize = 20, cooldownMs = 30_000 } = {}) {
    this.fn = fn;
    this.failureRateThreshold = failureRateThreshold;
    this.windowSize = windowSize;
    this.cooldownMs = cooldownMs;
    this.state = 'CLOSED';
    this.results = []; // rolling window of true/false (success/failure)
    this.openedAt = null;
  }

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

  #failureRate() {
    if (this.results.length < this.windowSize) return 0; // not enough data yet
    const failures = this.results.filter((r) => !r).length;
    return failures / this.results.length;
  }

  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error('Circuit is open — failing fast');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await this.fn(...args);
      this.#recordResult(true);
      if (this.state === 'HALF_OPEN') this.state = 'CLOSED';
      return result;
    } catch (err) {
      this.#recordResult(false);
      if (this.state === 'HALF_OPEN' || this.#failureRate() >= this.failureRateThreshold) {
        this.state = 'OPEN';
        this.openedAt = Date.now();
      }
      throw err;
    }
  }
}

Where to put the breaker

The breaker should live per downstream dependency, not globally — a failing recommendations service shouldn't trip a breaker that also gates calls to your payments provider. In a service with several external dependencies, that means one breaker instance per dependency, each with thresholds tuned to that dependency's actual failure characteristics (a flaky third-party API might warrant a lower threshold and longer cooldown than an internal service on the same network).

Takeaway

A circuit breaker's job isn't to prevent failures — it's to stop your own service from making a downstream failure worse by hammering it with load it can't handle, and to stop your own service from wasting its own resources waiting on calls that are very likely to fail anyway. The state machine is simple; the part worth getting right is choosing thresholds and window sizes that match how the dependency actually fails in practice, not just picking round numbers.

NewerOptimistic vs Pessimistic Locking in DatabasesOlderFeature Flags: A Practical Guide