Node.js Resilience in 2026: Timeouts, Retries, and Circuit Breakers That Actually Work

Set a hard timeout on every outbound call with AbortSignal.timeout(), then add retries with jittered backoff for transient failures, and wrap flaky dependencies in a circuit breaker so a slow service stops eating your event loop.
Part of the complete web development guide.
On this page
- Start with timeouts, because nothing else works without them
- Retries: the part everyone gets wrong
- Circuit breakers: stop calling what’s already broken
- Where this bites you on a smaller app
- A few things to check in your own code this week
- FAQ
- Do I need a library like opossum or Cockatiel for this?
- What timeout value should I actually use?
- Is a circuit breaker overkill for a small app?
Most Node.js outages I get called about aren’t crashes. The process is up, the health check is green, and the app is still “working.” It’s just that one downstream service got slow, every request piled up waiting on it, and the event loop turned into a parking lot. Resilience isn’t about surviving a hard crash. It’s about failing fast when something you depend on stops behaving.
That distinction matters more in 2026 than it did a few years ago. Node 22 and 24 are the current LTS lines, and the built-in fetch and AbortSignal are now the default way people call external services. That’s good news, because the primitives you need are already in the runtime. The bad news is that the defaults are still naive, and a lot of production code just calls fetch and hopes.
Start with timeouts, because nothing else works without them
If you take one thing from this article, take this: a request with no timeout is not a request, it’s a leak. Node’s fetch will happily wait forever if the server never responds. Meanwhile your handler holds a socket, a promise, and a chunk of memory. Do that a few thousand times and you’re done.
Modern Node gives you AbortSignal.timeout(), which is the cleanest way to cap a call:
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`, {
signal: AbortSignal.timeout(2000) // hard 2s cap
});
if (!res.ok) throw new Error(`upstream ${res.status}`);
return res.json();
}
Two seconds is a starting point, not a rule. The right number comes from your own latency data. Pull your p99 for that dependency, add some headroom, and set the timeout there. If your p99 is 400ms, a 30-second timeout isn’t resilience, it’s just a slower way to fail.
For CPU-bound work that isn’t I/O, timeouts won’t save you. A tight loop blocks the event loop and no signal fires until it yields. That’s a different problem, and the fix is worker threads or breaking the work into chunks, not a timeout.
Retries: the part everyone gets wrong
Retrying sounds free. It isn’t. Bad retries turn a small blip into a self-inflicted denial of service, because when a service is struggling, the last thing it needs is every client hammering it three times as hard.
A few rules I follow:
- Only retry idempotent operations. Retrying a POST that charges a card is how you get two charges.
- Only retry on transient failures. A 500 or a connection reset, sure. A 400 or 401, no. Retrying a malformed request just wastes everyone’s time.
- Use exponential backoff with jitter. Without jitter, all your clients retry in lockstep and you get a thundering herd.
- Cap total attempts. Three is usually right. Five is generous. Ten means you’ve given up on being a good citizen.
Here’s a small helper that does the job without pulling in a library:
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function withRetry(fn, { attempts = 3, base = 200 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const retryable = err.name === 'AbortError' ||
(err.status && err.status >= 500);
if (!retryable || i === attempts - 1) throw err;
// full jitter: random point between 0 and the backoff window
const window = base * 2 ** i;
await sleep(Math.random() * window);
}
}
throw lastErr;
}
The jitter line is the important one. Math.random() * window spreads retries across time instead of stacking them. If you remember one thing about backoff, remember that backoff without jitter is only half a fix.
Circuit breakers: stop calling what’s already broken
Timeouts and retries handle individual requests. A circuit breaker handles the dependency as a whole. When a service has failed enough times in a row, you stop calling it entirely for a while and fail fast instead. That gives the struggling service room to recover and keeps your own latency from ballooning.
The pattern has three states. Closed means normal operation. Open means we’re not calling it, we return an error immediately. Half-open means we let a few test requests through to see if it’s back.
class CircuitBreaker {
#state = 'closed';
#failures = 0;
#openedAt = 0;
constructor({ threshold = 5, cooldownMs = 10_000 } = {}) {
this.threshold = threshold;
this.cooldownMs = cooldownMs;
}
async call(fn) {
if (this.#state === 'open') {
if (Date.now() - this.#openedAt < this.cooldownMs) {
throw new Error('circuit open');
}
this.#state = 'half-open';
}
try {
const result = await fn();
this.#failures = 0;
this.#state = 'closed';
return result;
} catch (err) {
this.#failures++;
if (this.#state === 'half-open' || this.#failures >= this.threshold) {
this.#state = 'open';
this.#openedAt = Date.now();
}
throw err;
}
}
}
In production you’d want a counter for half-open successes and a little more state, but the shape is right. Wrap your flaky dependency calls in one of these and your app degrades instead of collapsing.
Where this bites you on a smaller app
You don’t need a microservices architecture to get burned by this. I’ve seen it on modest apps running on a single VPS, including plenty of client work I’ve done around Commack and the rest of Long Island. A site calls a payment API, a shipping API, or a third-party auth provider. That provider hiccups for ninety seconds. Because the app has no timeout, every incoming request queues behind a doomed call, and the whole site goes dark even though the only broken thing was one external service.
If you want an app that holds up, this is a core part of custom web development, not an afterthought you bolt on later. The architecture decisions about timeouts and failure handling belong in the first version, because retrofitting them once you have traffic is a lot more painful.
A few things to check in your own code this week
- Grep for every outbound call and confirm it has a timeout. No exceptions.
- Check that retries are idempotent and jittered. If you see a fixed
setTimeout(1000)in a retry loop, fix it. - Look at what happens when your slowest dependency dies. Does the app fail fast, or does it hang?
- Make sure health checks reflect dependency health, not just “is the process running.”
None of this is exotic. It’s a handful of patterns and maybe a hundred lines of code. The reason it matters is that the failure mode it prevents, a slow dependency taking down everything around it, is the single most common way real Node apps fall over in production. Handle it early and you’ll spend a lot less time at 2am wondering why the health check lied to you.
FAQ
Do I need a library like opossum or Cockatiel for this?
Not necessarily. For a handful of dependencies, a small class like the one above is easier to reason about and has no dependency risk. Reach for a library once you have many services, need metrics, or want tested edge cases you don’t want to write yourself.
What timeout value should I actually use?
Base it on your own data. Take the p99 latency for that dependency, add a bit of headroom, and set the timeout there. Round numbers like 2 or 5 seconds are fine as a starting point, but if your real p99 is 300ms, a 30-second timeout just makes failures slower.
Is a circuit breaker overkill for a small app?
If you depend on any external service you don’t control, no. Even one flaky API can take down a small site if requests pile up behind it. A basic breaker is maybe forty lines and it’s the difference between a degraded feature and a dead site.


