Circuit Breaker in Node.js and NestJS with Opossum, Fallback and Concurrency Control
Why Node services need fast failure
Node can handle many concurrent I/O operations, but that does not mean an unlimited number of pending requests is safe.
If a downstream supplier takes thirty seconds:
pending Promises
open sockets
response objects
memory
upstream requestsaccumulate.
A circuit breaker lets the service stop creating more slow work after the dependency crosses a health threshold.
Opossum-style breaker
import CircuitBreaker from 'opossum';
const options = {
timeout: 1800,
errorThresholdPercentage: 50,
resetTimeout: 15000,
volumeThreshold: 10,
};
const supplierBreaker =
new CircuitBreaker(
async (request: SupplierRequest) =>
supplierClient.search(request),
options,
);
supplierBreaker.fallback(
(request: SupplierRequest) => ({
supplier: request.supplier,
status: 'TEMPORARILY_UNAVAILABLE',
results: [],
}),
);The breaker should be a long-lived singleton/provider.
This is wrong:
async function call() {
const breaker = new CircuitBreaker(...);
return breaker.fire();
}A new breaker per request forgets all historical health information.
NestJS provider
@Injectable()
export class SupplierCircuit {
private readonly breaker: CircuitBreaker<
[SupplierRequest],
SupplierResponse
>;
constructor(
private readonly supplierClient: SupplierClient,
private readonly metrics: MetricsService,
) {
this.breaker = new CircuitBreaker(
(request) =>
this.supplierClient.search(request),
{
timeout: 1800,
errorThresholdPercentage: 50,
resetTimeout: 15000,
volumeThreshold: 10,
},
);
this.breaker.on('open', () =>
this.metrics.increment(
'supplier.breaker.open',
),
);
this.breaker.on('halfOpen', () =>
this.metrics.increment(
'supplier.breaker.half_open',
),
);
this.breaker.on('close', () =>
this.metrics.increment(
'supplier.breaker.close',
),
);
this.breaker.on('timeout', () =>
this.metrics.increment(
'supplier.timeout',
),
);
}
execute(request: SupplierRequest) {
return this.breaker.fire(request);
}
}Circuit breaker is not a bulkhead
If a dependency becomes slow before the breaker has enough samples to open, large concurrency can already accumulate.
I add a concurrency limit:
import pLimit from 'p-limit';
const supplierLimit = pLimit(20);
async function protectedSearch(
request: SupplierRequest,
) {
return supplierLimit(
() => supplierBreaker.fire(request),
);
}Now the dependency receives at most twenty concurrent requests from this instance.
Retry with Opossum
I normally place a very small retry inside the protected action or a shared HTTP client, not outside in several layers.
async function callSupplier(
request: SupplierRequest,
) {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
return await withTimeout(
() => supplierClient.search(request),
1200,
);
} catch (error) {
if (
attempt === 2 ||
!isTransient(error)
) {
throw error;
}
await sleep(
100 * 2 ** (attempt - 1) +
Math.floor(Math.random() * 70),
);
}
}
}The breaker sees the final action result while the retry count stays tightly bounded.
Fallback
Good search fallback:
{
"status": "DEGRADED",
"source": "SUPPLIER_A",
"results": []
}Good read fallback:
serve cached reference data
mark freshness timestampBad payment fallback:
pretend capture succeededMetrics
I track:
- breaker state transition;
- fires;
- failures;
- timeouts;
- rejected/not-permitted;
- fallback count;
- action duration;
- in-flight concurrency;
- queue/wait time around the bulkhead.
Event loop health
A remote I/O outage can coincide with local event-loop pressure.
I also observe:
event loop delay
heap used
active sockets
HTTP agent socket count
GC pausesA breaker protects dependency calls but does not fix CPU blocking inside the Node process.
Production checklist
- Register one breaker instance per dependency/policy.
- Use HTTP timeout below overall SLA.
- Add bounded concurrency.
- Keep retries small.
- Add jitter.
- Define explicit degraded response.
- Export state and rejection metrics.
- Monitor event-loop delay.
- Load-test dependency latency, not only HTTP 500.
- Test breaker recovery.
FAQ
Can NestJS use Resilience4j?
No, Resilience4j is Java. In Node/NestJS I use Node-native libraries or shared policy wrappers.
Should breaker state be centralized in Redis?
Usually not. Local breaker state is faster and avoids another dependency.
Does Opossum limit all concurrency?
Circuit breaking and concurrency limiting are different concerns. I add a semaphore/p-limit/bulkhead where capacity must be bounded.
Related architecture guides
- [Microservices Design Patterns I Have Used in Real Enterprise Projects](/articles/microservices-design-patterns-real-enterprise-projects)
- [Circuit Breaker with Resilience4j](/articles/circuit-breaker-resilience4j-spring-boot)
-
Architect's final take
In Node systems the dangerous failure is often not CPU—it is an unbounded number of slow I/O operations. I use breaker + timeout + concurrency limits together.
Your feedback helps prioritize deeper technical content.






