A Circuit Breaker State Machine in JavaScript
The 80-line breaker I drop in front of every flaky upstream. Three explicit states, a half-open probe, and a clock you can swap out for tests. No `opossum`, no `cockatiel`, no Redis.
By @vikramross
March 9, 2026
·
Updated May 20, 2026
407 views
3
4.5 (11)
// CircuitBreaker as a pure state machine. No fetch, no timers.
// Stage 1: prove the transitions. Stage 2 will wrap a real call.
const CLOSED = 'closed';
const OPEN = 'open';
const HALF_OPEN = 'half_open';
function createBreaker({ failureThreshold = 3, openMs = 5000, now = Date.now } = {}) {
let state = CLOSED;
let failures = 0;
let openedAt = 0;
function onSuccess() {
failures = 0;
state = CLOSED;
}
function onFailure() {
failures += 1;
if (state === HALF_OPEN || failures >= failureThreshold) {
state = OPEN;
openedAt = now();
}
}
function canPass() {
if (state === CLOSED) return true;
if (state === OPEN && now() - openedAt >= openMs) {
state = HALF_OPEN;
return true;
}
return state === HALF_OPEN ? false : false;
}
return {
get state() { return state; },
get failures() { return failures; },
canPass, onSuccess, onFailure,
};
}
// Drive the machine with a fake clock so the demo is deterministic.
let t = 0;
const clock = () => t;
const br = createBreaker({ failureThreshold: 2, openMs: 1000, now: clock });
console.log('start state:', br.state); // closed
br.onFailure();
console.log('after 1 fail:', br.state, br.failures); // closed, 1
br.onFailure();
console.log('after 2 fails:', br.state, br.failures); // open, 2
console.log('canPass while open early:', br.canPass()); // false
t = 1500; // advance past openMs
console.log('canPass after openMs:', br.canPass()); // true (half_open)
console.log('state during probe:', br.state); // half_open
br.onSuccess();
console.log('after probe success:', br.state, br.failures); // closed, 0Pulling the state machine out of the wrapper is the move that makes a circuit breaker testable. Three states, three transitions: CLOSED counts failures and trips OPEN at the threshold, OPEN refuses calls until openMs has passed and then flips to HALF_OPEN, HALF_OPEN lets exactly one probe through and either snaps back to CLOSED on success or back to OPEN on failure. The injectable now clock is the only reason this code is not flaky to test; once you have it you can fast-forward time without jest.useFakeTimers(). Notice that failures resets only on success, not on the OPEN-to-HALF_OPEN transition, so a flapping upstream cannot soft-reset the count by waiting.
// Stage 2: wrap a flaky upstream. Inlines the state machine from stage 1.
const CLOSED = 'closed', OPEN = 'open', HALF_OPEN = 'half_open';
function createBreaker({ failureThreshold = 3, openMs = 5000, now = Date.now } = {}) {
let state = CLOSED, failures = 0, openedAt = 0;
return {
get state() { return state; },
canPass() {
if (state === CLOSED) return true;
if (state === OPEN && now() - openedAt >= openMs) {
state = HALF_OPEN;
return true;
}
return false;
},
onSuccess() { failures = 0; state = CLOSED; },
onFailure() {
failures += 1;
if (state === HALF_OPEN || failures >= failureThreshold) {
state = OPEN; openedAt = now();
}
},
};
}
class CircuitOpenError extends Error {
constructor() { super('circuit_open'); this.code = 'circuit_open'; }
}
function guard(breaker) {
return async function call(fn) {
if (!breaker.canPass()) throw new CircuitOpenError();
try {
const result = await fn();
breaker.onSuccess();
return result;
} catch (err) {
breaker.onFailure();
throw err;
}
};
}
// Demo: call a flaky function until the breaker trips, then stops calling it.
let upstreamCalls = 0;
async function flaky() {
upstreamCalls++;
throw new Error('boom');
}
const breaker = createBreaker({ failureThreshold: 2, openMs: 60_000 });
const call = guard(breaker);
(async () => {
for (let i = 0; i < 5; i++) {
try { await call(flaky); } catch (e) { console.log(`attempt ${i + 1}: ${e.code || e.message}`); }
}
console.log('upstream was called', upstreamCalls, 'times; breaker:', breaker.state);
})();The guard factory takes a breaker and returns a callable wrapper. The shape matters: passing the work as await call(() => fetch(url)) keeps the breaker oblivious to what is being protected and lets one breaker guard several call sites that share an upstream. The CircuitOpenError has a stable code field so callers can distinguish a fast-fail (no work was attempted) from a real upstream error (work was attempted and failed). After the threshold is reached, every subsequent call short-circuits without ever invoking flaky, which is the entire point: stop pummelling a failing service while it tries to recover.
// Stage 3: prevent thundering-herd on the probe.
// Only ONE concurrent call may pass while half-open.
const CLOSED = 'closed', OPEN = 'open', HALF_OPEN = 'half_open';
function createBreaker({ failureThreshold = 3, openMs = 5000, now = Date.now } = {}) {
let state = CLOSED, failures = 0, openedAt = 0, probeInFlight = false;
return {
get state() { return state; },
canPass() {
if (state === CLOSED) return true;
if (state === OPEN && now() - openedAt >= openMs) {
state = HALF_OPEN;
probeInFlight = false;
return true;
}
if (state === HALF_OPEN && !probeInFlight) {
return true;
}
return false;
},
markProbeStart() { probeInFlight = true; },
markProbeEnd() { probeInFlight = false; },
onSuccess() { failures = 0; state = CLOSED; probeInFlight = false; },
onFailure() {
failures += 1; probeInFlight = false;
if (state === HALF_OPEN || failures >= failureThreshold) {
state = OPEN; openedAt = now();
}
},
};
}
class CircuitOpenError extends Error { constructor() { super('circuit_open'); this.code = 'circuit_open'; } }
function guard(breaker) {
return async function call(fn) {
if (!breaker.canPass()) throw new CircuitOpenError();
if (breaker.state === 'half_open') breaker.markProbeStart();
try {
const r = await fn();
breaker.onSuccess();
return r;
} catch (e) {
breaker.onFailure();
throw e;
}
};
}
// Demo: 5 callers race for the probe; only one wins, others fast-fail.
let t = 0;
const br = createBreaker({ failureThreshold: 1, openMs: 10, now: () => t });
const call = guard(br);
(async () => {
try { await call(async () => { throw new Error('boom'); }); } catch {}
t = 100; // past openMs
let probeCount = 0;
const probe = async () => { probeCount++; await new Promise(r => setTimeout(r, 5)); return 'ok'; };
const results = await Promise.allSettled([call(probe), call(probe), call(probe), call(probe), call(probe)]);
console.log('probe was called:', probeCount, 'times');
console.log('outcomes:', results.map(r => r.status === 'fulfilled' ? r.value : r.reason.code));
})();The naive half-open lets every queued caller race for the probe slot, which is exactly what you do not want against a recovering database. Adding a probeInFlight flag and gating canPass on it means the first caller wins and the rest get circuit_open immediately. Resetting the flag on both success and failure (and on the OPEN-to-HALF_OPEN transition) handles the case where a probe throws asynchronously. This is the version I ship; the previous one looks correct in unit tests but causes a real-world stampede the moment your retry budget aligns across instances.
