Circuit Breaker in JavaScript
Closed, open, half-open: stop calling a failing function, fail fast while open, and probe once when the cooldown expires.
- javascript
- reliability
- async
- interview
A flaky getUser 503s. The UI retries five times per click, ten clicks land, fifty requests hit a host that is already down. Latency goes to the timeout ceiling. A circuit breaker is the missing state machine: after enough consecutive failures you stop calling the function, fail fast, wait a cooldown, then send one probe. You are wrapping an async function. You are not writing a network library.
The three states are closed, open, and half-open. Open does not call fn. Half-open is a probe, not a chopped batch of every waiter.
Key takeaways
- Closed: call
fn. Consecutive failures increment. Hit the threshold, go open.- Open: reject immediately, do not call
fn, until the cooldown elapses.- Half-open: allow one in-flight probe (or a small N). Success closes and resets the counter. Failure opens again.
- Success in closed resets consecutive failures. A timeout of
fnis a failure iffnrejects.- Combine with retry and timeout explicitly. The breaker does not retry on its own.
Closed, open, half-open: stop calling a failing function
function circuitBreaker(fn, options = {}) {
const {
failureThreshold = 5,
cooldownMs = 10_000,
halfOpenMax = 1,
now = () => Date.now(),
} = options;
let state = 'closed';
let consecutiveFailures = 0;
let openedAt = 0;
let halfOpenInFlight = 0;
function rejectOpen() {
const err = new Error('Circuit open');
err.code = 'CIRCUIT_OPEN';
err.state = state;
return Promise.reject(err);
}
function onSuccess() {
state = 'closed';
consecutiveFailures = 0;
halfOpenInFlight = 0;
}
function onFailure() {
consecutiveFailures += 1;
if (state === 'half-open' || consecutiveFailures >= failureThreshold) {
state = 'open';
openedAt = now();
halfOpenInFlight = 0;
}
}
return async function wrapped(...args) {
if (state === 'open') {
if (now() - openedAt >= cooldownMs) {
state = 'half-open';
halfOpenInFlight = 0;
} else {
return rejectOpen();
}
}
if (state === 'half-open' && halfOpenInFlight >= halfOpenMax) {
return rejectOpen();
}
if (state === 'half-open') {
halfOpenInFlight += 1;
}
try {
const result = await fn.apply(this, args);
onSuccess();
return result;
} catch (err) {
onFailure();
throw err;
}
};
}
let calls = 0;
const flaky = async () => {
calls += 1;
throw new Error('503');
};
const guarded = circuitBreaker(flaky, { failureThreshold: 2, cooldownMs: 1000 });
await guarded().catch((e) => e.message); // '503', calls === 1, still closed
await guarded().catch((e) => e.message); // '503', calls === 2, now open
await guarded().catch((e) => e.code); // 'CIRCUIT_OPEN', calls still 2
State machine
| State | Call fn? |
Next on success | Next on failure |
|---|---|---|---|
| closed | yes | stay closed, consecutiveFailures = 0 |
increment; if at threshold, open |
| open | no | (does not happen) | (does not happen) |
| half-open | only if probes in flight < halfOpenMax |
closed, reset | open, reset cooldown |
Open is the whole point. A wrapper that still invokes fn while “open” is a retry counter with extra names.
Half-open must not dump a queue. Ten callers waiting on the cooldown would otherwise stampede the host the millisecond it expires. halfOpenMax = 1 means one probe. Everyone else keeps getting CIRCUIT_OPEN until that probe fulfills and the breaker closes. If you set N, you are still probing, not replaying the backlog.
Inject now so tests can jump the cooldown without sleep. In production Date.now is enough. Do not setTimeout to flip open to half-open on a timer unless you also clear it; a lazy check on the next call is fewer moving parts and is what this sketch does. A timer-based flip is valid if you want half-open to start without a caller.
How this composes with retry and timeout
The breaker does not retry. Retry N times is a loop around a factory. Timeout races fn against a timer and does not cancel the loser unless you pass an AbortSignal.
Pick an order and say it:
timeoutinsidefn(or wrapfnfirst): a hung call counts as a breaker failure when the timeout rejects.retryinside the wrappedfn: three inner attempts then one throw is one breaker failure. The host still saw three requests.retryoutside the breaker: each attempt is a breaker call. Open will fail-fast the remaining retries, which is usually what you want.
Do not hide the last error from fn. The open reject is a new error (CIRCUIT_OPEN) because fn never ran. Failures that happened while closed should rethrow the original err.
This is not AbortController. Opening the circuit does not abort in-flight work that already started in closed or half-open. Pair with cancelable tasks if a probe should be abortable.
Footguns
- Counting total failures instead of consecutive. A success must zero the counter or a noisy period opens the circuit hours later.
- Using a shared breaker across unrelated functions. One URL 500ing should not mute a different host. Key breakers by dependency.
- Half-open success that only decrements. Any successful probe closes.
- Treating 4xx as a reason to open. A breaker is for dependency health (5xx, timeouts, network), not for “the user sent a bad id.” Filter before
onFailureif you have status codes. - Sync
fnthat throws:asyncwrapped still goes tocatch. Fine. A sync wrapper would throw before the caller canawait. Keep the wrapperasyncso the open path is always a rejected promise, not a throw from the call site. halfOpenMaxgreater than 1: a late failure from a second probe can reopen after an earlier probe already closed. Prefer one probe, or ignore failures from a stale generation.
Interview answer (out loud)
I wrap an async function with three states. Closed calls fn and counts consecutive rejections. At the threshold I open: later calls reject immediately and do not invoke fn. After the cooldown the next call is half-open, one probe. Success closes and resets. Failure opens again. I do not let a queue of waiters through on half-open. I am not retrying inside the breaker; retry and timeout compose around it. This is not a network client.
Further reading
Related on this site
- Retry a promise N times
- Timeout a promise
- AbortController
- Cancelable async tasks
- Promises
- Map with limited concurrency
Related guides
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.