0Pricing
Node.js Backend Development Bootcamp · Lesson

Timeouts, Retries, and Exponential Backoff with Jitter

Bound every outbound call and retry transient failures without amplifying load on dependencies.

Timeouts, Retries, and Exponential Backoff with Jitter is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Bound Every Outbound Call

In a Node.js backend, every call that leaves your process (HTTP request, database query, message broker publish) is a place where you can hang forever. A slow dependency does not just delay one request, it pins an event-loop tick, holds a socket, and keeps a connection-pool slot busy.

  • Unbounded latency cascades: one stuck downstream call multiplies into thousands of stuck inbound requests.
  • Resource exhaustion: sockets, file descriptors, and pool connections leak while you wait.
  • No SLA without a deadline: you cannot promise a p99 latency if a call has no upper bound.

The first rule of production resilience: never make an outbound call without a timeout. Retries and backoff build on top of that bound.

Timeouts with AbortSignal.timeout

Modern Node.js (18+) ships AbortSignal.timeout(ms), which produces a signal that aborts automatically after the deadline. The global fetch accepts a signal, so bounding an HTTP call is a one-liner.

  • When the timeout fires, the promise rejects with an AbortError (err.name === 'AbortError').
  • The signal is fire-and-forget: no manual clearTimeout needed.
  • Always distinguish a timeout abort from other network errors so you log and retry correctly.
async function fetchWithTimeout(url, ms) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError' || err.name === 'TimeoutError') {
      throw new Error('Request timed out after ' + ms + 'ms');
    }
    throw err;
  }
}

// Demo against a hung promise (no real network)
function hang(signal) {
  return new Promise((_, reject) => {
    signal.addEventListener('abort', () => reject(signal.reason));
  });
}

(async () => {
  try {
    await hang(AbortSignal.timeout(50));
  } catch (e) {
    console.log('Aborted:', e.name);
  }
})();

Connect, Read, and Total Timeouts Are Different

"Timeout" is not a single number. A robust client distinguishes several deadlines:

  • Connect timeout: how long to wait for the TCP/TLS handshake.
  • Read / socket-idle timeout: how long to wait between bytes once connected.
  • Total / overall timeout: a hard wall on the entire operation, including retries.

A common bug is setting only a read timeout. A dependency that accepts the connection but never sends the first byte can still stall up to the socket-idle limit. The total budget is the one your caller actually feels, so always cap the whole retry sequence, not just each attempt.

Rule of thumb: perAttemptTimeout x maxAttempts should stay under your overall request budget, or you will blow your own SLA while retrying.

Retry Only Transient, Idempotent Failures

Retrying is dangerous if applied blindly. You must classify the failure before retrying:

  • Retryable (transient): connection reset, DNS hiccup, timeout, HTTP 502/503/504, and 429 (respecting Retry-After).
  • NOT retryable: 400 (bad request), 401/403 (auth), 404, 422. Retrying these just wastes load and never succeeds.
  • Idempotency matters: a GET or PUT is safe to repeat; a non-idempotent POST (charge a card) can double-execute. Use an idempotency key before retrying writes.
function isRetryable(err) {
  // Network-level errors thrown by Node
  const transientCodes = new Set([
    'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'
  ]);
  if (err.code && transientCodes.has(err.code)) return true;
  if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
  // HTTP status carried on the error
  if (err.status && [429, 502, 503, 504].includes(err.status)) return true;
  return false;
}

console.log(isRetryable({ code: 'ECONNRESET' }));   // true
console.log(isRetryable({ status: 503 }));           // true
console.log(isRetryable({ status: 404 }));           // false
console.log(isRetryable(new Error('bad json')));     // false

Fixed Delay Retries Are Not Enough

The naive retry waits a constant delay between attempts:

  • If a dependency is briefly overloaded, all your clients retry at the same fixed interval and hit it again together.
  • This creates a retry storm: the very act of retrying keeps the dependency down.
  • A constant short delay also wastes attempts when the outage lasts seconds, while a constant long delay wastes time on quick blips.

The fix is to grow the wait after each failure (exponential backoff) so pressure on the dependency decreases over time, and to add randomness (jitter) so clients do not synchronize. The next scenes build this up.

Exponential Backoff

Exponential backoff multiplies the delay after each failed attempt, typically by a base of 2:

  • delay = base * 2^attempt, e.g. with base 100ms: 100, 200, 400, 800ms.
  • Always cap the delay with a maxDelay so it does not grow to minutes.
  • The cap turns pure exponential growth into "capped exponential backoff", which is what you almost always want.

The intuition: a quick first retry catches one-off blips, while later attempts back off hard to give a struggling dependency room to recover.

function backoffDelay(attempt, base = 100, maxDelay = 2000) {
  const exp = base * 2 ** attempt;
  return Math.min(exp, maxDelay);
}

for (let attempt = 0; attempt < 6; attempt++) {
  console.log('attempt', attempt, '->', backoffDelay(attempt) + 'ms');
}
// 0->100, 1->200, 2->400, 3->800, 4->1600, 5->2000 (capped)

The Thundering Herd Problem

Pure exponential backoff still has a flaw: if 1,000 clients all failed at the same instant (a shared dependency blipped), they all compute the same delay and retry at the same future moment.

  • The dependency recovers, then gets hit by 1,000 simultaneous retries, and falls over again.
  • This synchronized wave is the thundering herd.
  • Capping the delay does not help, it just synchronizes the herd at the cap.

The cure is jitter: add randomness to each client's delay so the retries spread out across a window instead of landing on the same tick. Jitter is not optional polish, it is the part that actually protects the dependency.

Full Jitter

The AWS-recommended strategy is full jitter: compute the capped exponential ceiling, then pick a uniformly random delay between 0 and that ceiling.

  • cap = min(maxDelay, base * 2^attempt)
  • delay = random(0, cap)

This spreads retries evenly across the whole window, minimizing collisions. Compared to "equal jitter" (half fixed + half random), full jitter generally yields the lowest contention and fewest total calls under load. The small cost is that an individual retry can fire very early, which is fine because the goal is to de-synchronize the herd.

function fullJitterDelay(attempt, base = 100, maxDelay = 2000) {
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

// Show how 5 "clients" spread out on the same attempt
for (let client = 0; client < 5; client++) {
  console.log('client', client, 'attempt 3 delay:', fullJitterDelay(3) + 'ms');
}
// Each client gets a different value in [0, 800)

Putting It Together: retryWithBackoff

Now combine bounding, classification, capped exponential backoff, and full jitter into one reusable helper. Key design points:

  • Each attempt is independently bounded by a timeout.
  • Only retryable errors trigger another attempt; everything else throws immediately.
  • After the last attempt, rethrow so the caller can fail fast.
  • An overall deadline (not shown here) should still wrap the whole loop in production.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function fullJitter(attempt, base = 100, maxDelay = 2000) {
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

async function retryWithBackoff(fn, { retries = 4, isRetryable } = {}) {
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastErr = err;
      if (attempt === retries || !isRetryable(err)) throw err;
      const delay = fullJitter(attempt);
      console.log('attempt', attempt, 'failed, waiting', delay + 'ms');
      await sleep(delay);
    }
  }
  throw lastErr;
}

// Simulate a flaky call that succeeds on the 3rd try
let calls = 0;
retryWithBackoff(
  async () => {
    calls++;
    if (calls < 3) { const e = new Error('flaky'); e.status = 503; throw e; }
    return 'ok after ' + calls + ' calls';
  },
  { retries: 4, isRetryable: (e) => e.status === 503 }
).then(console.log);

Respect Retry-After and Cap the Total Budget

Two production-grade refinements make the difference between a polite client and a load amplifier:

  • Honor Retry-After: on 429 or 503, the server may tell you exactly how long to wait. Always prefer that value over your computed backoff, it is the dependency asking for room.
  • Enforce an overall deadline: track a budget (e.g. 3s). Before sleeping, if now + delay would exceed the deadline, stop retrying and fail fast instead of blowing your SLA.

The combination keeps each retry bounded, the total bounded, and lets the dependency steer its own recovery.

function nextDelay(err, attempt, base = 100, maxDelay = 5000) {
  const header = err.retryAfterSeconds; // parsed from Retry-After
  if (typeof header === 'number') return header * 1000;
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

const deadline = Date.now() + 3000; // 3s total budget
let attempt = 0;
const err = { status: 429, retryAfterSeconds: 1 };
const delay = nextDelay(err, attempt);

if (Date.now() + delay > deadline) {
  console.log('Budget exhausted, fail fast');
} else {
  console.log('Honoring Retry-After, sleeping', delay + 'ms');
}

Retries Need a Circuit Breaker Above Them

Retries handle transient failures. They are the wrong tool for a sustained outage: if a dependency is hard-down, every request retrying 4 times multiplies your outbound load by 5x at the worst possible moment.

  • Layer a circuit breaker above the retry helper. When failures cross a threshold, the breaker opens and short-circuits calls instantly (fail fast) instead of retrying.
  • After a cool-down it goes half-open, lets a probe through, and closes again on success.
  • Order of layers: breaker -> retry -> timeout. The timeout bounds each attempt, retry handles blips, the breaker stops the bleeding during real outages.

This is the full resilience stack for outbound calls in a Node.js backend.

Quick Check

You operate a Node.js service whose downstream payment provider briefly returns 503 during a deploy. Thousands of your instances all retry. Which single change most directly prevents your retries from re-overloading the provider the instant it recovers?

Recap

You learned how to bound and retry outbound calls without amplifying load:

  • Bound everything: no outbound call without a timeout; distinguish connect, read, and total deadlines via AbortSignal.timeout.
  • Classify before retrying: retry only transient, idempotent failures (timeouts, ECONNRESET, 429/502/503/504); never retry 400/401/404/422.
  • Capped exponential backoff: grow the delay after each failure, but cap it with maxDelay.
  • Full jitter: pick a random delay in [0, cap) to break the thundering herd. This is the part that protects the dependency.
  • Respect Retry-After and a total budget: let the server steer recovery and fail fast before blowing your SLA.
  • Top it with a circuit breaker: breaker -> retry -> timeout handles outages, blips, and slow calls respectively.

Frequently asked questions

Is the “Timeouts, Retries, and Exponential Backoff with Jitter” lesson free?

Yes — the full text of “Timeouts, Retries, and Exponential Backoff with Jitter” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Timeouts, Retries, and Exponential Backoff with Jitter”?

Bound every outbound call and retry transient failures without amplifying load on dependencies. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Node.js Backend Development Bootcamp?

No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Timeouts, Retries, and Exponential Backoff with Jitter” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Node.js Backend Development Bootcamp lesson?

Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Timeouts, Retries, and Exponential Backoff with Jitter
  2. The Circuit Breaker Pattern and Bulkhead Isolation
  3. Graceful Shutdown and In-Flight Request Draining
  4. Health Checks, Readiness Probes, and Load Shedding
← Back to Node.js Backend Development Bootcamp