Node.js Circuit Breakers with opossum + TypeScript (2026)
June 30, 2026
A Node.js circuit breaker wraps a call to a flaky dependency and "trips" after too many failures, so your service fails fast with a fallback instead of piling up on a dead upstream. This tutorial builds one with opossum and TypeScript, drives it through every state, and verifies the output.
TL;DR
You will wrap an async call in an opossum circuit breaker, tune exactly when it trips, add a fallback, stop expected 4xx errors from opening it, and put it behind an Express route. Stack: opossum 9.0.01 (zero runtime dependencies), TypeScript 6.0.3, Express 5.2.12, Node 22 LTS (opossum needs Node 20+). Every transition — closed → open → half-open → closed — is run in the sandbox and the real output is pasted in. Budget about 25 minutes.
Version note. As of June 2026,
npm i opossuminstalls 10.0.0 (shipped June 24, 2026). This guide pins 9.0.0 for a battle-tested baseline; 10.0.0's only breaking change is dropping Node 20 support, so every snippet here is identical on 10.x as long as you run Node 22 or newer.3
What you'll learn
- How the circuit breaker pattern stops one slow dependency from taking down your whole service
- How to wrap any async function in an opossum breaker and read its three states
- How to tune exactly when the breaker trips: error threshold, volume threshold, and the rolling window
- How to add a fallback for graceful degradation — and where the fallback actually runs
- How to keep expected 4xx errors from tripping the breaker with
errorFilter - How to put a per-dependency breaker behind an Express route
- How to combine the breaker with timeouts and retries in the right order
- How to observe state changes and clean up on shutdown
Prerequisites
- Node.js 20+ (this guide uses 22.x; opossum 9 drops support for Node 16 and 183)
- npm 10+
- Basic TypeScript and
async/await - A terminal; no Docker, database, or cloud account needed — the "remote" dependency is simulated locally
Step 1 — The failure you're actually preventing
Picture an order service that calls an inventory service on every request. One afternoon, inventory gets slow — not down, just slow, answering in 8 seconds instead of 80 milliseconds. Each waiting request holds a socket and an event-loop continuation. Within seconds your order service has hundreds of in-flight calls parked on a dependency that will never answer in time, and now it stops answering too. The failure has cascaded.
A circuit breaker is the fuse box for this. The pattern was popularized by Michael Nygard in Release It! (2007) and widely described by Martin Fowler4: wrap the risky call, count failures, and once the failure rate crosses a threshold, open the circuit — stop calling the dependency entirely and return immediately. After a cooldown, let a single probe through to see if the dependency recovered. That is the whole idea, and opossum implements it so you do not have to.
Set up a project:
mkdir circuit-breaker-demo && cd circuit-breaker-demo
npm init -y
npm pkg set type=module
npm i opossum@9.0.0
npm i -D typescript@6.0.3 tsx@4.22.4 @types/opossum@8.1.9 @types/node@22
opossum ships as CommonJS with zero runtime dependencies, but it imports cleanly into an ES-module project.1 It does not bundle its own types, so we add @types/opossum from DefinitelyTyped.5 Create tsconfig.json:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}
We will run TypeScript directly with tsx and type-check with tsc --noEmit — no build step. allowImportingTsExtensions lets us import local files with their .ts extension, which nodenext resolution and Node 22's native type stripping both require (tsx accepts an extension or none).
Step 2 — Your first breaker and its three states
Create a stand-in for the remote call. A real breaker wraps fetch, a database driver, or an SDK method; here a toggle simulates the dependency going down so the demo is deterministic. Save src/inventory.ts:
// A stand-in for a remote call. Flip the toggle to simulate the upstream
// going down or recovering.
let down = true;
export function setUpstream(state: 'up' | 'down') {
down = state === 'down';
}
export async function getInventory(sku: string): Promise<{ sku: string; qty: number }> {
await new Promise((resolve) => setTimeout(resolve, 20)); // simulated latency
if (down) throw new Error('HTTP 503 from inventory-service');
return { sku, qty: 42 };
}
Now wrap it. Save src/breaker.ts:
import CircuitBreaker from 'opossum';
import { getInventory } from './inventory.ts';
export const inventoryBreaker = new CircuitBreaker<[string], { sku: string; qty: number }>(
getInventory,
{
timeout: 200, // reject any call slower than 200ms
errorThresholdPercentage: 50, // open once the failure rate exceeds 50%
resetTimeout: 1000, // after opening, wait 1s before probing again
volumeThreshold: 3, // need at least 3 calls before the breaker may trip
name: 'inventory',
},
);
The generic CircuitBreaker<[string], { sku: string; qty: number }> types the action's argument tuple and return value, so inventoryBreaker.fire('SKU-1') is fully typed.5 You call the wrapped function with breaker.fire(...args), which returns a promise.6
A breaker is always in one of three states, exposed as boolean getters: closed (calls flow through normally), opened (calls fail fast without touching the dependency), and halfOpen (a single probe call is allowed through to test recovery).6 We will watch all three next.
Step 3 — Tuning exactly when the breaker trips
This is where most tutorials stop and most production incidents start. Here are opossum's defaults, read straight from the 9.0.0 source, and what each one actually controls:6
| Option | Default | What it controls |
|---|---|---|
timeout | 10000 ms | Per-call deadline; a slower call is counted as a failure. false disables it. |
errorThresholdPercentage | 50 | Failure rate (over the window) above which the circuit opens. |
volumeThreshold | 0 | Minimum calls in the window before the breaker may trip. 0 means it can open on the first failure. |
resetTimeout | 30000 ms | Time spent open before a single half-open probe is allowed. |
rollingCountTimeout | 10000 ms | Length of the rolling stats window. |
rollingCountBuckets | 10 | Number of buckets the window is split into. |
Two of these defaults bite people, so be explicit about them.
errorThresholdPercentage is a strict greater-than. On each failure, opossum computes errorRate = failures / fires * 100 over the rolling window and opens the circuit only when errorRate > errorThresholdPercentage.6 At exactly 50% it does not open — you need to exceed the threshold.
volumeThreshold defaults to 0, which means the breaker can open on the very first failure. Set it to a realistic floor (10–20 in production) so a single unlucky request during a traffic lull does not trip the breaker. The flip side, which trips up tests: with volumeThreshold: 3, the breaker will not open until at least three calls have been recorded in the window, no matter how badly they fail. If your test "proves" the breaker never opens, this is usually why.
The rollingCountTimeout (10s) and rollingCountBuckets (10) define the stats window: opossum keeps the last 10 seconds of results in ten 1-second buckets and rolls them forward, so the error rate reflects recent behavior rather than all-time history.6
Step 4 — Fallbacks: graceful degradation done right
A breaker that throws when it opens just moves the failure around. A fallback lets you degrade gracefully — serve cached data, a default, or a queued write. Register one with .fallback(). Add this to src/breaker.ts:
// Called with the original args PLUS the error as the final argument.
inventoryBreaker.fallback((sku: string) => ({ sku, qty: 0 }));
Two details that the docs are quiet about but the source is clear on.6 First, the fallback receives the original arguments with the error appended as the last argument, so you can branch on why the call failed. Second — and this surprises people — the fallback runs on every failure, not only when the circuit is open. A failed call in the closed state still invokes the fallback; opening the circuit just means future calls skip the dependency and go straight to it. When a fallback returns a value, fire() resolves with that value rather than rejecting, so your call site sees a successful (if degraded) result.
If you would rather surface the open state to the caller — say, to return HTTP 503 — omit the fallback. Then a call while the circuit is open rejects with an error whose code is 'EOPENBREAKER' and message is 'Breaker is open', and a call that exceeds timeout rejects with code 'ETIMEDOUT' and message 'Timed out after 200ms'.6 You choose per dependency whether degrade-silently or fail-loudly is the right behavior.
Step 5 — Don't let expected 4xx errors trip the breaker
Here is a real bug hiding in most copy-paste breakers: a 404 Not Found or 422 Unprocessable Entity is the dependency working correctly, but to a naive breaker it is just another rejected promise, and enough of them will open your circuit and take a healthy service offline.
errorFilter fixes this. It is a function that receives the error; return true for errors that should not count as failures.6 Update the options in src/breaker.ts:
export const inventoryBreaker = new CircuitBreaker<[string], { sku: string; qty: number }>(
getInventory,
{
timeout: 200,
errorThresholdPercentage: 50,
resetTimeout: 1000,
volumeThreshold: 3,
name: 'inventory',
// 4xx means the request was understood and answered — not a dependency outage.
errorFilter: (err: { statusCode?: number }) =>
err.statusCode !== undefined && err.statusCode >= 400 && err.statusCode < 500,
},
);
The semantics are precise and worth getting right: when errorFilter returns true, opossum emits a success event (so the call counts as a success in the stats and pushes the breaker away from opening) but still rejects the promise with the original error.6 In other words, the filter changes the breaker's bookkeeping, not your error handling — your catch still runs. Here is that behavior, run in the sandbox with a function that always throws a 404:
8x HTTP-404 WITH errorFilter(4xx) => state CLOSED, failures=0, fires=8
3x HTTP-404 WITHOUT errorFilter => state OPEN, failures=3
With the filter, eight 404s leave the circuit closed and the failure count at zero. Without it, the third 404 trips the circuit open (this breaker's volumeThreshold is 3, and a 100% error rate clears the 50% threshold). That is the difference between a resilient service and an outage caused by a typo in a URL.
Step 6 — A per-dependency breaker behind an Express route
Now wire a breaker around a real fetch and expose it. The cardinal rule: one breaker per dependency, created once and reused — never a fresh breaker per request, which would have no memory and never trip. Save src/server.ts:
import express from 'express';
import CircuitBreaker from 'opossum';
async function fetchInventory(sku: string): Promise<{ sku: string; qty: number }> {
const res = await fetch(`http://localhost:4101/inventory/${sku}`);
if (!res.ok) {
const error: Error & { statusCode?: number } = new Error(`inventory ${res.status}`);
error.statusCode = res.status;
throw error;
}
return (await res.json()) as { sku: string; qty: number };
}
const inventory = new CircuitBreaker<[string], { sku: string; qty: number }>(fetchInventory, {
timeout: 300,
errorThresholdPercentage: 50,
resetTimeout: 1000,
volumeThreshold: 3,
name: 'inventory',
});
inventory.fallback((sku: string) => ({ sku, qty: 0 }));
const app = express();
app.get('/inventory/:sku', async (req, res) => {
const data = await inventory.fire(req.params.sku);
res.json({ data, breaker: inventory.opened ? 'open' : 'closed' });
});
app.listen(4102, () => console.log('order-service on http://localhost:4102'));
Because a fallback is registered, fire() always resolves, so the route always returns 200 with either live data (qty: 42) or degraded data (qty: 0). If you preferred to fail loudly, you would drop the fallback and wrap fire() in a try/catch that returns res.status(503) when err.code === 'EOPENBREAKER'. Run it against a flaky upstream and the breaker does its job — degraded responses while the dependency is down, then automatic recovery:
req#1 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"closed"}
req#2 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"closed"}
req#3 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#4 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#5 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#6 http=200 body={"data":{"sku":"SKU-1","qty":42},"breaker":"closed"}
The circuit trips open on the third failure (once volumeThreshold is met), serves the fallback instantly without touching the dead upstream, and closes itself again after the dependency recovers.
Step 7 — Timeouts and retries, in the right order
A breaker is one of three resilience controls that work together; the other two are timeouts and retries, and the ordering matters.
The timeout belongs inside the breaker, which is exactly what opossum's timeout option gives you — and why it defaults to 10 seconds rather than off.6 A call that overruns is counted as a failure and contributes to opening the circuit. Disable it (timeout: false) and a dependency that hangs never produces a "failure," so the breaker never trips — the precise scenario from Step 1. Keep the timeout, and set it well below your upstream's worst acceptable latency.
Retries go outside the breaker, not inside it. If you retry inside the wrapped function, every retry burst counts as a single fire(), you hammer a struggling dependency, and the breaker can't see the individual failures. Put the breaker closest to the dependency and retry the breaker:
async function fireWithRetry(sku: string, attempts = 3): Promise<{ sku: string; qty: number }> {
for (let i = 1; i <= attempts; i++) {
try {
return await inventory.fire(sku);
} catch (err) {
const isOpen = (err as { code?: string }).code === 'EOPENBREAKER';
if (isOpen || i === attempts) throw err; // don't retry a fast-failing open circuit
await new Promise((r) => setTimeout(r, 100 * 2 ** (i - 1))); // exponential backoff
}
}
throw new Error('unreachable');
}
This pattern fits a breaker without a fallback: an open circuit makes fire() reject instantly with EOPENBREAKER, so the retry loop bails out immediately instead of waiting on backoff for a dependency it already knows is down. If you registered a fallback (Step 4), fire() resolves with the degraded value instead and there is nothing to retry — fallbacks and retries are alternative strategies, so pick one per dependency. Either way, the breaker and the retry cooperate rather than fight.
Step 8 — Observe state changes and clean up on shutdown
opossum is an EventEmitter, and the events are how you get the breaker into your logs and dashboards. The full set includes fire, success, failure, timeout, reject, open, halfOpen, close, and fallback, among others.6 Subscribe to the ones that matter for alerting:
inventory.on('open', () => console.warn('[breaker:inventory] OPEN — failing fast'));
inventory.on('halfOpen', () => console.info('[breaker:inventory] HALF_OPEN — probing'));
inventory.on('close', () => console.info('[breaker:inventory] CLOSED — recovered'));
inventory.on('timeout', () => console.warn('[breaker:inventory] call timed out'));
The breaker also exposes running counters via inventory.stats — fires, successes, failures, timeouts, fallbacks, and rejects — which map directly onto a Prometheus exporter if you are already scraping metrics. (See the related guide on custom Prometheus metrics in Node.js for the wiring.)
Finally, two lifecycle calls. breaker.shutdown() permanently disables a breaker: it emits a final shutdown event, removes the breaker's listeners, and clears its internal reset and rolling-stats timers.6 You do not need it just to let your process exit — opossum creates every timer with .unref(), so none of them keep Node alive — but it is the clean way to tear a breaker down in a test's afterEach, or during graceful shutdown alongside the SIGTERM discipline from the Kubernetes zero-downtime deployments guide:
process.on('SIGTERM', () => {
inventory.shutdown();
process.exit(0);
});
The other is breaker.healthCheck(fn, interval), which runs fn every interval ms (default 5000) and opens the circuit whenever fn rejects — a way to trip the breaker from an out-of-band signal, like a failing database ping, before user requests even hit it.6
Verification: drive every state and read the output
The proof a breaker works is watching it transition. This script forces the full cycle — closed under load, open after failures, half-open probe, reopen on a still-failing probe, then close on recovery. Save src/demo.ts:
import CircuitBreaker from 'opossum';
import { getInventory, setUpstream } from './inventory.ts';
const breaker = new CircuitBreaker<[string], { sku: string; qty: number }>(getInventory, {
timeout: 200, errorThresholdPercentage: 50, resetTimeout: 1000, volumeThreshold: 3, name: 'inventory',
});
breaker.fallback((sku: string) => ({ sku, qty: 0 }));
breaker.on('open', () => console.log(' >> EVENT: open'));
breaker.on('halfOpen', () => console.log(' >> EVENT: halfOpen'));
breaker.on('close', () => console.log(' >> EVENT: close'));
const state = () => (breaker.opened ? 'OPEN' : breaker.halfOpen ? 'HALF_OPEN' : 'CLOSED');
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fire(tag: string) {
const out = await breaker.fire('SKU-1').catch((e: { code?: string }) => ({ err: e.code }));
const s = breaker.stats;
console.log(`${tag} state=${state()} result=${JSON.stringify(out)} ` +
`fires=${s.fires} failures=${s.failures} fallbacks=${s.fallbacks}`);
}
for (let i = 1; i <= 4; i++) await fire(`fire#${i}`); // upstream down -> trips open
await sleep(1100); await fire('probe-A'); // half-open, still down -> reopen
setUpstream('up'); await sleep(1100); await fire('probe-B'); // half-open, healthy -> close
breaker.shutdown();
Run it with npx tsx src/demo.ts. Type-check first with npx tsc --noEmit. This is the actual sandbox output:
fire#1 state=CLOSED result={"sku":"SKU-1","qty":0} fires=1 failures=1 fallbacks=1
fire#2 state=CLOSED result={"sku":"SKU-1","qty":0} fires=2 failures=2 fallbacks=2
>> EVENT: open
fire#3 state=OPEN result={"sku":"SKU-1","qty":0} fires=3 failures=3 fallbacks=3
fire#4 state=OPEN result={"sku":"SKU-1","qty":0} fires=4 failures=3 fallbacks=4
>> EVENT: halfOpen
>> EVENT: open
probe-A state=OPEN result={"sku":"SKU-1","qty":0} fires=5 failures=4 fallbacks=5
>> EVENT: halfOpen
>> EVENT: close
probe-B state=CLOSED result={"sku":"SKU-1","qty":42} fires=6 failures=4 fallbacks=5
Read it top to bottom: failures accumulate while closed, the circuit opens on the third failure, and the open-state call (fire#4) leaves failures flat — an open-circuit rejection counts as a reject, not a failure. probe-A shows the half-open probe executing the real (still failing) call and reopening; probe-B shows the recovered probe closing the circuit and returning live data (qty: 42). The state machine works exactly as advertised.
Troubleshooting
The breaker never opens in my tests. You almost certainly set volumeThreshold higher than the number of calls your test makes. opossum won't trip until at least volumeThreshold calls are recorded in the rolling window.6 Either send more requests or lower the threshold for the test.
A handful of 404s opened my circuit. Expected client errors are counting as failures. Add an errorFilter that returns true for 4xx status codes (Step 5) so they register as successes for the breaker while still rejecting to your caller.
A breaker carries state between my tests. A module-scoped breaker (the right pattern for reuse) keeps its stats and listeners for the life of the process, so its state bleeds across tests. Call breaker.shutdown() in afterEach to disable it, clear its timers, and remove listeners. opossum creates its timers with .unref(), so a forgotten breaker won't hang your process — it just won't reset itself.6
import CircuitBreaker from 'opossum' fails to type-check. opossum is CommonJS and ships no types. Install @types/opossum and keep esModuleInterop: true in your tsconfig.5 The default import is correct — opossum exports the class via module.exports.
One global breaker for everything. A single breaker shared across unrelated dependencies means a flaky payment API can open the circuit for your healthy inventory API. Create one breaker per downstream dependency, each with its own name, and reuse it across requests.
Next steps and further reading
A circuit breaker is one guardrail; durable retries with a dead-letter queue are another. For asynchronous workloads, pair this with the NATS JetStream durable worker retries and DLQ tutorial, and feed breaker.stats into the Prometheus metrics setup so an open circuit pages someone. From here, add a capacity limit (a bulkhead) to cap concurrent in-flight calls, and a healthCheck to trip the circuit proactively when an out-of-band probe — say, a database ping — starts failing.
Footnotes
-
opossum — npm package (version 9.0.0, CommonJS, zero dependencies,
engines.node^20 || ^22 || ^24). https://www.npmjs.com/package/opossum ↩ ↩2 -
Express 5.x — official documentation. https://expressjs.com/ ↩
-
opossum CHANGELOG — 10.0.0 (2026-06-24) removes Node 20 support; 9.0.0 removes Node 16 and 18 support. https://github.com/nodeshift/opossum/blob/main/CHANGELOG.md ↩ ↩2
-
Martin Fowler — "CircuitBreaker" (pattern description; origin in Michael Nygard's Release It!, 2007). https://martinfowler.com/bliki/CircuitBreaker.html ↩
-
@types/opossum — DefinitelyTyped type definitions (
CircuitBreaker<TI, TR>generics, options, events). https://www.npmjs.com/package/@types/opossum ↩ ↩2 ↩3 -
nodeshift/opossum — README, API reference, and source (
lib/circuit.js): default options, state transitions, events,errorFilter, andfallbacksemantics. https://github.com/nodeshift/opossum ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15