Quick Answer

A circuit breaker wraps calls to an external service and tracks failures. While calls succeed it stays closed and lets everything through. Once failures cross a threshold it opens and rejects every call instantly without attempting it, for a cooldown period. After the cooldown it goes half-open and allows one trial call: success closes it again, failure re-opens it. This stops a struggling service from drowning under retries and lets your app fail fast instead of hanging.

Why failing fast beats waiting

When service B is down, every call from service A to B hangs until it times out - maybe 30 seconds. Each of those hanging calls holds a connection and a request slot the whole time. Under load, A's request queue fills with requests all stuck waiting on B, and now A is unresponsive too. One service's outage has cascaded.

Retries make it worse: they add load to B exactly when it is least able to cope, so B never gets a chance to recover.

Failing fast breaks both problems. If A already knows B is down, it returns an error or a fallback in microseconds instead of blocking for 30 seconds, and it stops sending B traffic so B can drain its backlog and come back. The circuit breaker is the component that "knows B is down."

Closed, open, half-open

Closed is normal: calls pass through and the breaker counts failures. Cross the threshold - say three failures in a row - and it trips to open. In the open state every call is rejected immediately; the real service is never contacted. A timer runs for the cooldown period. When it expires the breaker goes half-open and lets a trial call through: success returns it to closed with counters reset, failure sends it straight back to open.

class CircuitBreaker {
  constructor({ failureThreshold = 3, openMs = 5000, now = () => Date.now() } = {}) {
    Object.assign(this, { failureThreshold, openMs, now, state: 'CLOSED', failures: 0, openedAt: 0 });
  }
  async call(fn) {
    if (this.state === 'OPEN') {
      if (this.now() - this.openedAt >= this.openMs) this.state = 'HALF_OPEN';
      else throw new CircuitOpenError();
    }
    try {
      const result = await fn();
      this.failures = 0; this.state = 'CLOSED';        // success resets everything
      return result;
    } catch (err) {
      this.failures++;
      if (this.state === 'HALF_OPEN' || this.failures >= this.failureThreshold) {
        this.state = 'OPEN'; this.openedAt = this.now();
      }
      throw err;
    }
  }
}
healthy call        state=CLOSED    result=ok (service hit: true)
failure 1           state=CLOSED    threw=Error (service hit: true)
failure 2           state=CLOSED    threw=Error (service hit: true)
failure 3 -> trips  state=OPEN      threw=Error (service hit: true)
call while OPEN     state=OPEN      threw=CircuitOpenError (service hit: false)
call while OPEN     state=OPEN      threw=CircuitOpenError (service hit: false)
after cooldown      state=CLOSED    result=ok (service hit: true)

The key line in that output is service hit: false. While the breaker is open the wrapped function is never called - the rejection is instant and free, which is the entire point.

Not every error should trip the breaker

A 404 or 400 means your request was wrong. Retrying will not help, and tripping the breaker on it is actively harmful: one client sending bad requests would open the circuit for every other caller. The breaker needs a predicate that only counts transient, server-side failures - timeouts, connection errors, and 500/502/503/504.

const countsEverything  = new CircuitBreaker({ failureThreshold: 3 });
const countsServerErrors = new CircuitBreaker({
  failureThreshold: 3,
  isFailure: (e) => e.status >= 500,       // 4xx does not count
});

// fire five 404s at each
for (const b of [countsEverything, countsServerErrors]) {
  for (let i = 0; i < 5; i++) await b.call(() => { throw httpError(404); }).catch(() => {});
}
after 5 client-side 404s:
  counts-everything breaker: OPEN   <- one bad client took the service offline for everyone
  counts-5xx-only breaker  : CLOSED

Also worth counting as failures: calls that are extremely slow. A request that returns after 20 seconds technically succeeded, but it is a symptom of the same trouble, so many breakers treat calls slower than a threshold as failures too.

The gotcha: half-open can let a flood through

A naive half-open check is just "cooldown elapsed, allow calls." But if 200 requests are queued when the cooldown expires, all 200 see "cooldown elapsed" and all 200 hit the recovering service at once - the exact spike that knocks it back down. You want one trial call; everyone else stays rejected until it resolves.

// 50 requests arrive together, just after the cooldown expires
async function run(BreakerClass) {
  const b = new BreakerClass({ openMs: 5000, now: () => 10_000 });
  let hits = 0;
  const service = async () => { hits++; await sleep(20); return 'ok'; };
  await Promise.allSettled(Array.from({ length: 50 }, () => b.call(service)));
  return hits;
}
naive   : recovering service was hit 50 time(s) by the 50 concurrent requests
guarded : recovering service was hit 1 time(s) by the 50 concurrent requests

The guarded version sets a trialInFlight flag when it promotes to half-open and rejects everyone else until the trial call comes back. Production libraries - opossum for Node, resilience4j for Java, Polly for .NET - handle this for you, along with rolling-window failure counting and events you can alarm on.

Using it in practice

One breaker per dependency, not one global breaker. Payments being down should not open the circuit to search.

Pair it with a fallback: cached data, a sensible default, a degraded response, or a "we will process this shortly" queue. A breaker with no fallback just turns a slow failure into a fast one - still a failure to the user.

Breaker state is per process. Ten instances of your service each keep their own counts, so a recovering service can see up to ten trial calls in half-open, not one. That is usually acceptable; if it is not, centralise the state in something like Redis.

Tune with real numbers - too low a threshold trips on normal blips, too long a cooldown keeps you degraded after recovery. And combine it with timeouts (so calls fail in bounded time) and backoff retries (for the transient blips that should never reach the breaker). Build one yourself once to understand it, then use a library in production.

Frequently Asked Questions

What's the difference between a circuit breaker and a retry? A retry re-attempts one failed call, assuming the failure is transient. A circuit breaker tracks failures across many calls and stops attempting entirely when a service looks broadly down. They are used together.
What are the three circuit breaker states? Closed (calls pass through, failures counted), open (all calls rejected instantly for a cooldown), and half-open (one trial call allowed after the cooldown to test whether the service recovered).
Should a 404 trip the circuit breaker? No. A 4xx means the request itself was wrong, not that the service is failing. Only count timeouts, connection errors, and 5xx responses, otherwise one misbehaving client can open the circuit for everyone.
What happens to requests while the circuit is open? They are rejected immediately without contacting the real service. Your code should catch that and return a fallback - cached data, a default, or a clear temporarily-unavailable response.
Is circuit breaker state shared across server instances? Not by default - each process keeps its own counts and state. With N instances a recovering service can get up to N trial calls in half-open unless you centralise the state.