What does a circuit breaker actually do?

Asked constantlyintermediate2–12 yrs11 min read

It stops calling a dependency that is clearly failing, so the caller fails fast instead of queueing behind a timeout. Three states — and the half-open one is the whole design, because it is how the breaker finds out the dependency recovered without letting all the traffic back in at once.

The Answer

Say this in the room. 45 seconds.

  • A breaker watches calls to one dependency. When enough of them fail, it stops calling it and fails immediately instead.
  • Closed — calls pass through, failures are counted. Open — calls are rejected without touching the dependency. Half-open — one trial call is allowed through.
  • Half-open is the design. Without it the breaker would never know the dependency came back, and you would be choosing between staying open forever and letting all the traffic back at once.
  • The point is not to protect the dependency. It is to stop your threads queueing behind a timeout, which is how one sick service takes down the services calling it.
  • It is not a retry. A retry assumes the failure is transient; a breaker has concluded it is not. Put the retry inside the breaker, never the breaker inside the retry.
  • A breaker with no timeout on the underlying call protects nothing — you cannot count failures that have not happened yet.

Understand It

The problem is your threads, not their service

When a dependency stops responding, the expensive part is not the errors. It is that every one of your request threads is sitting in a socket read waiting for a timeout, so your thread pool fills with work that is guaranteed to fail. Requests that had nothing to do with that dependency then queue behind them, and a failure over there becomes an outage over here.

Without a breaker, every call goes through and waits:

Compiled and run on this build
Dependency raw = new Dependency();
int failures = 0;
for (int i = 0; i < 20; i++) {
    try { raw.fetch(); } catch (RuntimeException e) { failures++; }
}

System.out.println("  attempts               : 20");
System.out.println("  reached the dependency : " + raw.calls);
System.out.println("  failures               : " + failures);
Output
  attempts               : 20
  reached the dependency : 20
  failures               : 20

Twenty attempts, twenty timeouts, twenty threads held for the duration of each. The dependency told us it was down on the first call and we asked another nineteen times.

With a breaker in front of it, the first few failures are enough:

Compiled and run on this build
Clock clock = new Clock();
Dependency dep = new Dependency();
CircuitBreaker breaker = new CircuitBreaker(3, 1000, clock);

int rejected = 0, failed = 0;
for (int i = 0; i < 20; i++) {
    try { breaker.call(dep::fetch); }
    catch (OpenCircuitException e) { rejected++; }
    catch (RuntimeException e) { failed++; }
}

System.out.println("  attempts                 : 20");
System.out.println("  reached the dependency   : " + dep.calls);
System.out.println("  rejected without calling : " + rejected);
System.out.println("  state                    : " + breaker.state());
Output
  attempts                 : 20
  reached the dependency   : 3
  rejected without calling : 17
  state                    : OPEN

Three calls instead of twenty. The other seventeen failed immediately — no socket, no timeout, no thread held. That is the entire benefit: your caller degrades fast instead of degrading slowly, and fast failure is what keeps the rest of your service alive.

Note what did not happen: the seventeen rejected calls still failed. A breaker does not make anything work. It changes how long failing takes, and that is worth an outage.

Half-open is the whole design

An open breaker has to reopen eventually, and the naive options are both bad: stay open until a human intervenes, or close on a timer and send the full traffic back at a service that may still be dead.

Half-open is the third option — after a cool-down, let one call through and decide based on what happens:

Compiled and run on this build
Clock clock = new Clock();
Dependency dep = new Dependency();
CircuitBreaker breaker = new CircuitBreaker(3, 1000, clock);
for (int i = 0; i < 5; i++) {
    try { breaker.call(dep::fetch); } catch (RuntimeException e) { }
}

System.out.println("  after the failures        : " + breaker.state());
clock.advance(1000);
System.out.println("  after the cool-down       : " + breaker.state());

int before = dep.calls;
try { breaker.call(dep::fetch); } catch (RuntimeException e) { }
System.out.println("  trial call reached it?    : " + (dep.calls > before));
System.out.println("  still failing, so state   : " + breaker.state());

clock.advance(1000);
dep.down = false;
System.out.println("  dependency recovered      : " + breaker.call(dep::fetch));
System.out.println("  state                     : " + breaker.state());
Output
  after the failures        : OPEN
  after the cool-down       : HALF_OPEN
  trial call reached it?    : true
  still failing, so state   : OPEN
  dependency recovered      : ok
  state                     : CLOSED

The transitions are exact because the clock is injected rather than slept on — which is also how you should test one.

Read the middle: the trial call reached the dependency and failed, and the breaker went straight back to open without a second attempt. One probe per cool-down is the rule. A service that is struggling to recover does not need your traffic helping it decide.

Then the dependency recovers, the next probe succeeds, and the breaker closes. Nobody was paged and no configuration changed.

Choosing the numbers

This is the follow-up question, and the useful answer is about consequences rather than defaults:

SettingToo lowToo high
Failure thresholdOpens on a blip; a single slow moment cuts off a healthy serviceNever opens; you keep the outage you were trying to contain
Cool-downProbes a sick service constantly, which is the load you removedStays down long after recovery
Timeout on the callCounts healthy-but-slow calls as failuresThreads still queue — the breaker never sees a failure to count

Two things matter more than the numbers.

Count a rate, not a run. Counting consecutive failures — as the model above does, for brevity — means one lucky success resets everything, so a dependency failing half the time never opens the breaker. Real implementations use a rolling window and a failure percentage, and this is the single most common misconfiguration.

One breaker per dependency. A breaker is a statement about one downstream service. Share one across two and a failure in the unimportant one blocks the important one.

What it is not

It is not a retry, and the two interact badly if nested the wrong way. A retry assumes the failure is transient. A breaker has concluded it is not. Put the retry inside the breaker so that its attempts count as one logical call — the other way round, the retry keeps hammering a breaker that is trying to stay open, and each retry burns one of your open-circuit rejections.

It is not a fallback. The breaker decides whether to call; a fallback decides what to return when you do not. They are usually used together, and the fallback is a per-call product decision: a stale price is fine, a stale balance is not.

It is not a bulkhead. A bulkhead limits how much of your capacity one dependency can consume — a separate pool per downstream — so a slow service cannot take every thread even before the breaker opens. Breakers stop calls after failures start; bulkheads bound the damage while they are happening.

And it protects the caller, not the callee. Reduced load may help the dependency recover, which is a side effect. The reason to have one is that your service stays responsive.


Reference

The correct implementation, the configuration that goes with it, and the variants worth knowing. Copy from here.

A complete breaker

Production-shaped: a rolling window, a failure rate, injected time so it is testable, and a lock so it is usable from many threads.

public final class CircuitBreaker {

    public enum State { CLOSED, OPEN, HALF_OPEN }

    private final int windowSize;         // e.g. 20
    private final int failurePercent;     // e.g. 50
    private final long openMillis;        // e.g. 10_000
    private final LongSupplier clock;     // System::currentTimeMillis in production

    private final boolean[] window;
    private int index, recorded, failures;
    private State state = State.CLOSED;
    private long openedAt;

    public CircuitBreaker(int windowSize, int failurePercent, long openMillis, LongSupplier clock) {
        this.windowSize = windowSize;
        this.failurePercent = failurePercent;
        this.openMillis = openMillis;
        this.clock = clock;
        this.window = new boolean[windowSize];
    }

    public synchronized State state() {
        if (state == State.OPEN && clock.getAsLong() - openedAt >= openMillis) return State.HALF_OPEN;
        return state;
    }

    public <T> T call(Supplier<T> action) {
        State entered = state();                       // one read, outside the call
        if (entered == State.OPEN) throw new OpenCircuitException();
        try {
            T result = action.get();                   // NOT holding the lock
            onSuccess(entered);
            return result;
        } catch (RuntimeException e) {
            onFailure(entered);
            throw e;
        }
    }

    private synchronized void onSuccess(State entered) {
        record(false);
        if (entered == State.HALF_OPEN) reset();       // only clear on the transition
        state = State.CLOSED;
    }

    private synchronized void onFailure(State entered) {
        record(true);
        if (entered == State.HALF_OPEN || rateExceeded()) {
            state = State.OPEN;
            openedAt = clock.getAsLong();
            reset();
        }
    }

    private void record(boolean failed) {
        if (window[index]) failures--;                 // evict the outgoing outcome
        window[index] = failed;
        if (failed) failures++;
        index = (index + 1) % windowSize;
        if (recorded < windowSize) recorded++;
    }

    private void reset() { Arrays.fill(window, false); index = recorded = failures = 0; }

    /** Never judged on a partial window — one bad call is not a 100% failure rate. */
    private boolean rateExceeded() {
        return recorded == windowSize && failures * 100 >= windowSize * failurePercent;
    }
}

Three things in there are the difference between this and a sketch: the dependency is called outside the lock, the window is cleared only on the half-open transition, and the rate is never judged on a partial window.

With Resilience4j, which is what you will actually use

resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20
        minimumNumberOfCalls: 20          # do not judge a partial window
        failureRateThreshold: 50          # percent, not a count
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 1
        slowCallDurationThreshold: 2s     # slow counts as failed
        slowCallRateThreshold: 50
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - com.example.NotFoundException   # a 404 is an answer, not a failure
  timelimiter:
    instances:
      pricing:
        timeoutDuration: 2s               # without this the breaker sees nothing
@CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice")
@Retry(name = "pricing")                  // retry INSIDE the breaker
public Price fetch(String sku) {
    return client.get(sku);
}

/** Same signature plus the Throwable. Runs for both failures and rejections. */
private Price cachedPrice(String sku, Throwable cause) {
    return cache.getIfPresent(sku);       // may be null — decide what that means
}

Spring applies @Retry inside @CircuitBreaker by default, which is the ordering you want. Do not reverse it with @Order.

The timeout, which is the part that is actually load-bearing

@Bean
RestClient pricingClient(RestClient.Builder builder) {
    var factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(Duration.ofSeconds(1));
    factory.setReadTimeout(Duration.ofSeconds(2));      // no default — none at all
    return builder.requestFactory(factory).baseUrl(pricingUrl).build();
}

Variants

You wantUse
Open on failure count, dead dependencyslidingWindowType: COUNT_BASED, high threshold
Open on failure rate over timeTIME_BASED with slidingWindowSize in seconds
Treat slow as failedslowCallDurationThreshold + slowCallRateThreshold
Never trip on client errorsignoreExceptions for 4xx-mapped types
Limit concurrent calls tooa bulkhead alongside, not instead

Scenarios

Real situations, with the decision and the argument. Some of these have no clean answer, which is the point.

1. Your payment gateway starts failing 30% of calls. Do you put a breaker on it?

Probably not, and this is the scenario that catches people who have just learned the pattern.

A breaker converts a slow failure into a fast one, which is right when a fast failure is acceptable. For a payment you would rather wait and succeed than fail immediately, and rejecting 100% of payments because 30% are failing turns a partial outage into a total one for revenue.

What you want here is a timeout plus a bounded retry with jitter, and a bulkhead so payment calls cannot consume every thread. If you do add a breaker, the threshold belongs far higher than a read path's — and the fallback has to be "queue it for later", not "tell the user no".

The general rule: breaker where a fallback exists, retry where the operation is idempotent and worth waiting for.

2. One tenant's webhook endpoint is down. Every tenant's notifications stop.

One breaker keyed by dependency, and "the webhook service" was treated as one dependency when it is really one per tenant URL.

Key the breaker by what actually fails — here, per destination host. Resilience4j does this with a registry: registry.circuitBreaker("webhook-" + host). Watch the cardinality; per-tenant is fine at hundreds and a memory leak at millions, so it needs eviction.

This is the same reasoning as one breaker per dependency, applied one level down. The unit is not the class you call, it is the thing that can fail independently.

3. The breaker opens in staging every deploy and nobody trusts it any more.

The dependency restarts during a deploy, a handful of calls fail, and with a small window and a low threshold that is enough. The team's fix was to disable it.

The real fix is minimumNumberOfCalls. A window of 20 with a minimum of 5 will open on five failures during a restart; a minimum of 20 will not. In a low-traffic environment the window fills so slowly that any blip looks like a trend.

Worth saying plainly: a breaker that opens on deploys will be turned off, and then it is not there during the outage. Configuration that cries wolf is worse than none.

4. You put a breaker in front of your own database. Good idea?

Usually not. A breaker suits a dependency you can degrade without: a recommendations service, a pricing cache, an enrichment call. Most requests cannot do anything useful without the database, so opening the breaker converts "slow" into "down" and buys nothing.

What the database needs instead is a bounded connection pool with a short acquisition timeout — which is a bulkhead, and it gives you the fast failure without the state machine.

The exception is a read replica used for reporting where stale or absent data is acceptable. That is a real fallback, so a breaker earns its place.

5. During an incident the breaker is open and the dependency has recovered, but traffic is heavy.

One trial call per cool-down means recovery is discovered by a single request, and with a ten-second cool-down under a thousand requests a second, 9,999 of them fail while one probes.

That is the trade, and it is usually right — but if the cool-down is long and the traffic is heavy, shorten the cool-down rather than admitting more probes. Some implementations allow a small number of half-open calls, which recovers faster and risks re-loading a service that is still fragile.

There is no configuration that is right for both a service that recovers in one second and one that takes five minutes. Pick for the dependency you have, and say which you picked for.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "What does a circuit breaker do?" It watches calls to one dependency and, once enough fail, stops calling it and fails immediately. Three states: closed, open, half-open.

2. "What is the half-open state for?" Discovering that the dependency recovered without sending all the traffic back at once. After a cool-down, one trial call is allowed: success closes the breaker, failure re-opens it for another cool-down.

3. "Why does failing fast help? The request still fails." Because the cost of a failing dependency is your threads waiting on timeouts. Failing in microseconds instead of seconds keeps the pool free for requests that have nothing to do with it — that is what stops one service's outage becoming yours.

4. "How would you pick the threshold and cool-down?" From the dependency's normal error rate and recovery time. Too low and a blip cuts off a healthy service; too high and it never opens. More important than the numbers: count a failure rate over a rolling window rather than consecutive failures, or a dependency failing half the time never trips it.

5. "Retry and circuit breaker together — which wraps which?" Retry inside the breaker, so a retried call counts as one logical attempt. The other way round, the retries hammer a breaker trying to stay open and each attempt consumes a rejection.

6. "What must you have for the breaker to work at all?" A timeout on the underlying call. Without one there is no failure to count and your threads still queue — the breaker sits closed while the service dies.

7. "One breaker for all your downstreams?" No, one per dependency. A shared breaker lets an unimportant failing service block calls to a healthy critical one.

8. "What is a bulkhead and how is it different?" A limit on how much of your capacity one dependency may consume — typically a separate thread pool per downstream. It bounds damage while failures are happening; the breaker stops calls after they have.

Code traps

Trap A — predict before you run:

@CircuitBreaker(name = "pricing")
public Price fetch(String sku) {
    return restTemplate.getForObject(pricingUrl + sku, Price.class);
}
Answer

If that RestTemplate has no connect and read timeouts configured — and by default it has none — the breaker is decorative. A hung dependency produces no exception, so no failure is counted, so the breaker never opens, and every thread that enters this method stays there.

The breaker is the second line of defence. The timeout is the first, and this is the most common way a resilience annotation ends up protecting nothing.

Trap B:

@Retryable(maxAttempts = 3)
@CircuitBreaker(name = "inventory")
public Stock check(String sku) { ... }
Answer

The retry is on the outside, so each user request makes three attempts through the breaker. During an outage that triples the calls the breaker has to reject, and once it opens each user request burns three rejections instead of one.

Worse, if the retry catches the open-circuit exception it will retry that too — turning a fast rejection back into three. Put the breaker outside and the retry inside, so a retried call is one logical attempt from the breaker's point of view.

Trap C:

breaker.setFailureThreshold(5);   // consecutive failures
Answer

Against a dependency failing 50% of the time, this breaker never opens. Every other call succeeds and resets the counter, so five in a row essentially never happens — while half of all user requests fail.

Count a failure rate over a rolling window instead: "50% of the last 20 calls" opens correctly under partial failure, which is what real outages look like. Total failure is the easy case; the intermittent one is where the configuration matters.

Common wrong answers

Said in interviewsReality
"It retries the failed call."It does the opposite — it stops calling.
"It protects the downstream service."It protects the caller's threads. Reduced load is a side effect.
"Half-open means half the traffic goes through."One trial call per cool-down.
"It makes the request succeed."The rejected calls still fail, just immediately.
"Threshold means consecutive failures."Use a rate over a window, or partial failure never trips it.
"The breaker handles a hung dependency."Only if the call has a timeout. Otherwise there is nothing to count.

Check Yourself

Q1. The rejected calls still fail. What has the breaker bought you?

AnswerTime. A rejection costs microseconds where a timeout costs seconds, so your threads stay free for requests that do not touch the failing dependency. The failure is contained instead of spreading to everything sharing the pool.

Q2. Why is one trial call the right amount in half-open?

AnswerIt is enough to learn whether the dependency recovered and small enough not to be load on something still struggling. Closing fully would send all the traffic back at a service that may still be dead; staying open forever needs a human.

Q3. Your breaker is configured for five consecutive failures and never opens, while half of all requests fail. Why?

AnswerEvery success resets the counter, so five in a row almost never occurs under a 50% failure rate. Count a failure percentage over a rolling window instead — partial failure is the normal shape of an outage, and consecutive counting only catches total failure.


Practice

TierExerciseTime
Warm-upBuild the three states10 min
ChallengeMake the numbers defensible25 min
ProductionThe breaker that never opened45 min
InterviewFull round replay10 min

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

  • micro retries and backoff — not written yet
  • micro bulkheads — not written yet
  • executor service — not written yet

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.