Ch. 6 · Node.js

Node.js Retries with Backoff and Jitter

Write a Node.js retry helper that retries only transient errors, spreads attempts with jittered backoff and respects a deadline.

~7 min readadvancedupdated Oct 19, 2026

A retry is a bet that the next attempt will go better than the last. For a dropped TCP connection or a server restarting behind a load balancer, that’s a good bet. For a request with a malformed body, it’s a guaranteed loss, repeated. And when a dependency is failing because it’s overloaded, an aggressive retry policy turns every client into an extra source of load at the worst possible moment.

That’s why “add retries” is a design question, not a one-liner. This guide builds a small retry helper for Node.js, explains each choice in it, and ends with the interview question about retry storms. The code ran on Node 22.23.

Decide what is worth retrying

Start by sorting failures into two groups.

Transient, worth another attempt:

  • Network errors with codes like ECONNRESET (the other side closed the connection), ETIMEDOUT, ECONNREFUSED while a service restarts, and EAI_AGAIN (a temporary DNS failure).
  • 503 Service Unavailable and 502/504 from a gateway, which usually mean the upstream was briefly unreachable.
  • 429 Too Many Requests, but only after waiting as long as the server asks.
  • Your own timeout firing, if the operation is safe to repeat (more on that below).

Permanent, where retrying wastes time and load:

  • 400, 422: the request is wrong and will still be wrong.
  • 401, 403: the credentials won’t improve in 100 ms.
  • 404, 409: the state of the world won’t change by asking again.
  • An AbortError because the caller cancelled. Retrying cancelled work is a classic bug: the user closed the page, and your service keeps hammering the dependency on their behalf.

500 Internal Server Error sits in between. It might be a transient crash or a bug that fails every time. Most policies retry it once or twice, which is what the helper below does by treating every 5xx as retryable.

Exponential backoff, and why jitter matters

If the first retry happens immediately, it usually hits the same problem. So wait, and wait longer each time: 100 ms, 200 ms, 400 ms, 800 ms. That’s exponential backoff, and it gives a struggling service progressively more room. Cap it (say at 2 seconds) so a long retry sequence doesn’t produce absurd waits.

Backoff alone has a flaw. Picture 1,000 clients whose requests failed at the same moment because a database failed over. With plain exponential backoff, all 1,000 retry at exactly +100 ms, then all at +300 ms, then +700 ms. The load arrives in synchronised waves, and each wave can knock the recovering service over again. Adding randomness, called jitter, breaks the synchronisation. “Full jitter” picks the delay uniformly between zero and the exponential value.

A small simulation makes the difference concrete. For 1,000 clients making three retries each, I counted how many retries landed in the busiest 10 ms window:

busiest 10 ms window, no jitter:   1000 retries
busiest 10 ms window, full jitter: 163 retries
Text

The same total number of retries, but without jitter every single one of the first wave arrives in the same 10 ms. The jittered numbers vary a little from run to run, since they’re random; the gap between them doesn’t.

A retry helper you can read in one sitting

import { setTimeout as sleep } from 'node:timers/promises';

const RETRYABLE_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN']);

function isRetryable(err) {
  if (err.name === 'AbortError') return false;
  if (err.status) return err.status === 429 || err.status >= 500;
  return RETRYABLE_CODES.has(err.code);
}

export async function retry(fn, {
  attempts = 4, baseMs = 100, capMs = 2000, deadlineMs = 5000, signal,
} = {}) {
  const deadline = Date.now() + deadlineMs;
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      if (attempt >= attempts || !isRetryable(err)) throw err;
      const backoff = Math.random() * Math.min(capMs, baseMs * 2 ** (attempt - 1));
      const delay = Math.max(backoff, (err.retryAfter ?? 0) * 1000);
      if (Date.now() + delay > deadline) throw err;
      console.log(`  attempt ${attempt} failed (${err.message}); retrying in ${Math.round(delay)} ms`);
      await sleep(delay, undefined, { signal });
    }
  }
}
JavaScript

Walking through the decisions:

  • await fn(attempt) inside try. The await matters. Writing return fn(attempt) without it returns the promise before it settles, so a rejection skips the catch entirely and nothing is ever retried. It’s an easy bug to miss in review.
  • Two limits. attempts caps how many times you try. deadlineMs caps how long the whole operation can take, because the caller (an HTTP request with its own timeout, a user waiting) has a budget. If the next wait would overshoot the deadline, give up now rather than sleep and fail later.
  • The last error is rethrown. Callers see the real failure (HTTP 503), not a generic “retries exhausted”. If you wrap it, keep the original as cause.
  • Retry-After wins over backoff. When a server says how long to wait, waiting less is impolite and usually pointless. The helper expects the caller to parse the header into seconds on the error object. A real implementation would also handle the HTTP-date form of the header.
  • sleep takes the caller’s signal. If the request is cancelled mid-wait, timers/promises rejects with an AbortError and the loop stops. Without that, a cancelled operation keeps retrying in the background.

Here’s the helper against fake operations that fail in scripted ways:

transient failures:
  attempt 1 failed (HTTP 503); retrying in 52 ms
  attempt 2 failed (read ECONNRESET); retrying in 26 ms
  ok after 3 calls
client error:
  gave up immediately: HTTP 400
always failing:
  attempt 1 failed (HTTP 503); retrying in 50 ms
  attempt 2 failed (HTTP 503); retrying in 195 ms
  attempt 3 failed (HTTP 503); retrying in 82 ms
  gave up: HTTP 503
rate limited with Retry-After: 1
  attempt 1 failed (HTTP 429); retrying in 1000 ms
  ok after 2 calls
deadline shorter than Retry-After:
  gave up: HTTP 429
Text

Two details in that output are worth pointing at. In the “always failing” case, the third delay (82 ms) is shorter than the second (195 ms). That’s full jitter working as intended: the ceiling doubles each time, and the actual delay is random beneath it. And in the last case, the server asked for 30 seconds against a 5-second deadline, so the helper failed fast instead of holding the caller’s request open for nothing.

Idempotency: which requests are safe to repeat

There’s a failure mode the classification above can’t see. Your POST to a payment provider times out after 10 seconds. Did the charge happen? You don’t know. The request may have reached the server and succeeded, with only the response lost. Retrying it may charge the customer twice.

GET, PUT and DELETE are idempotent by definition in HTTP: doing them twice leaves the same state as doing them once. POST isn’t. The standard fix is an idempotency key, a unique id the client generates once per logical operation (crypto.randomUUID()) and sends on every attempt, usually as an Idempotency-Key header. The server records the key with the result of the first successful attempt and returns that stored result for any repeat. Stripe’s API works this way, and you can apply the same pattern to your own services and queue consumers. Generate the key outside the retry loop; a new key per attempt defeats the purpose.

If neither the operation nor the API supports idempotency, don’t retry on timeout. Retry only errors that prove the request never arrived, such as ECONNREFUSED, and surface the rest.

Where retries belong in a call chain

Retries multiply. If the browser retries 3 times, your API gateway retries each of those 3 times, and your service retries each database call 3 times, a single failing query can be attempted 27 times, all while the database is already in trouble. Pick one layer to own retries for a given call, usually the one closest to the failing dependency, and make the others fail fast.

Retries also don’t help with a dependency that’s down for minutes. That’s a job for a circuit breaker: after a run of failures, stop calling the dependency for a while and fail immediately, then let a trial request through to see if it has recovered. Another protection is a retry budget, where retries may be at most, say, 10% of requests over a window, so retries can absorb blips but can’t double the load during an outage.

Practice question: the retry storm

“Our checkout service calls an inventory service. Inventory had a 20-second database failover. Inventory’s own metrics show traffic jumping to about four times normal during the failover, and it stayed unhealthy for six minutes, long after the database was back. Checkout uses a retry wrapper with attempts: 4 and a fixed 100 ms delay. Explain what happened and how you’d change the retry policy.”

Answer. During the failover, nearly every request from checkout failed, so nearly every request became four requests: the original plus three retries. That’s the 4x traffic. Because the delay was a fixed 100 ms with no jitter, retries from all checkout instances arrived in synchronised bursts. When the database came back, inventory faced the normal load plus a backlog of retries arriving in waves, its latency rose, those slow responses hit checkout’s timeouts, and the timeouts triggered more retries. The retries themselves kept inventory overloaded after the original cause had cleared.

I’d change four things. Use exponential backoff with full jitter, so retries spread out and slow down while inventory struggles. Cap attempts lower, at two or three, and give the whole call a deadline tied to checkout’s own request timeout. Add a retry budget or circuit breaker, so that when most calls are failing, checkout stops retrying and fails fast with a clear error, sending inventory only occasional probe requests. And check idempotency: if the call reserves stock, it needs an idempotency key, or the retries during the failover may have reserved items twice. I’d verify the change in a staging failover test by watching inventory’s request rate stay close to normal during and after the outage.

Further reading

More in Node.js

read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
read ✓Node.js · hard

Node.js Clustering Across CPU Cores

Fork one Node.js worker per core with cluster, replace workers that crash, and plan for the state that each process keeps to itself.

~7 min readread →
read ✓Node.js · hard

Node.js Password Hashing with scrypt

Hash passwords in Node.js with crypto.scrypt, a random salt and stored cost parameters, then verify logins with timingSafeEqual.

~7 min readread →
esc