Home/Blog

The Circuit Breaker Pattern: Failing Fast on Purpose

When Service A calls Service B and B is struggling, the naive thing happens by default: A keeps calling anyway. Every failed call ties up a thread or a connection on A while it waits for a response that may never come well. Those failed calls pile up on B too, making a bad situation…

When Service A calls Service B and B is struggling, the naive thing happens by default: A keeps calling anyway. Every failed call ties up a thread or a connection on A while it waits for a response that may never come well. Those failed calls pile up on B too, making a bad situation worse. And if something upstream of A is waiting on A, the failure cascades outward, one unhealthy service can take down a chain of healthy ones.

A circuit breaker exists to stop this before it spirals. The idea is borrowed directly from electrical engineering: when current (requests) would damage the circuit (an overwhelmed service), you open the breaker and stop the flow entirely, giving the downstream system room to recover.

Three states, one simple state machine

  • Closed: requests flow normally. The breaker is just counting failures in the background.
  • Open: requests are blocked immediately without even attempting the call. A timer (nextAttempt) determines when to try again.
  • Half-open: a trial state. A limited number of requests are allowed through to test if the downstream service has recovered. Enough successes flip the breaker back to closed; continued failures send it back to open.

A minimal implementation

class CircuitBreaker {
  constructor({ failureThreshold = 5, successThreshold = 2, cooldownMs = 10_000 }) {
    this.state = 'CLOSED';
    this.failureThreshold = failureThreshold;
    this.successThreshold = successThreshold;
    this.cooldownMs = cooldownMs;
    this.failureCount = 0;
    this.successCount = 0;
    this.nextAttempt = Date.now();
  }

  async exec(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit open: call suspended');
      }
      this.state = 'HALF_OPEN';
    }

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

  onSuccess() {
    if (this.state === 'HALF_OPEN') {
      this.successCount++;
      if (this.successCount >= this.successThreshold) {
        this.state = 'CLOSED';
        this.failureCount = 0;
        this.successCount = 0;
      }
    }
  }

  onFailure() {
    this.failureCount++;
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.cooldownMs;
    }
  }
}

Usage is just wrapping the risky call:

const breaker = new CircuitBreaker({ failureThreshold: 5, cooldownMs: 10_000 });

async function chargeCard(order) {
  return breaker.exec(() => paymentGateway.charge(order));
}

In production you’d typically reach for a maintained library (opossum for Node.js, Resilience4j or Spring Cloud Circuit Breaker for the JVM world) rather than hand-rolling this, but understanding the state machine underneath makes configuring those libraries far less mysterious.

What to do when the breaker is open

Opening the circuit stops the bleeding, but the caller still needs something to return. Three common fallbacks:

  1. Redirect to a replica or secondary provider if one exists.
  2. Return a sensible default from middleware instead of an error.
  3. Serve from cache: stale data is often better than no data.
Previous
Keep reading

More from the lab

Building something like this?

Tell us the problem, not the solution. We read everything that comes in.