Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

Retry with exponential backoff and full jitter in 20 lines of JavaScript

Language: English · Code tested on Node.js Network calls fail. Retrying immediately in a tight loop makes things worse: if a service is struggling, a thousand clients hammering it in sync keeps it down. The standard fix

Language: English · Code tested on Node.js

Network calls fail. Retrying immediately in a tight loop makes things worse: if a service is struggling, a thousand clients hammering it in sync keeps it down. The standard fix is exponential backoff with jitter.

The function

async function retry(fn, {
  retries = 4, base = 100, cap = 2000,
  sleep = (ms) => new Promise(r => setTimeout(r, ms)),
  random = Math.random,
  shouldRetry = () => true,
} = {}) {
  let attempt = 0;
  for (;;) {
    try { return await fn(attempt); }
    catch (err) {
      if (attempt >= retries || !shouldRetry(err)) throw err;
      const delay = Math.floor(random() * Math.min(cap, base * 2 ** attempt)); // full jitter
      await sleep(delay);
      attempt++;
    }
  }
}
module.exports = { retry };

Why "full jitter"?

Plain exponential backoff waits 100, 200, 400, 800 ms. But if 10,000 clients failed at the same moment, they all retry at the same moments too. Full jitter picks a random delay between 0 and the exponential ceiling, which spreads retries out. In AWS's well-known simulations, it finished the same work with far fewer total calls than un-jittered backoff.

Don't retry everything

shouldRetry matters. Retry timeouts, ECONNRESET, 429, and 5xx. Don't retry 400, 401, 404: they will fail the same way every time. And only retry non-idempotent requests (like POST /payments) if you send an idempotency key.

Tests (no frameworks, no fake timers)

Because sleep and random are injected, the test runs instantly and deterministically:

const assert = require('assert');
const { retry } = require('./retry');
(async () => {
  const delays = []; let calls = 0;
  const v = await retry(async () => { if (++calls < 3) throw new Error('fail'); return 'ok'; },
    { sleep: async ms => delays.push(ms), random: () => 0.999 });
  assert.strictEqual(v, 'ok');
  assert.strictEqual(calls, 3);
  assert.deepStrictEqual(delays, [99, 199]);

  await assert.rejects(retry(async () => { throw new Error('x'); }, { retries: 2, sleep: async () => {} }), /x/);

  let n = 0;
  await assert.rejects(retry(async () => { n++; throw new Error('400'); },
    { sleep: async () => {}, shouldRetry: e => e.message !== '400' }));
  assert.strictEqual(n, 1);
  console.log('all tests passed');
})();
$ node retry.test.js
all tests passed

Production checklist

  • Cap the total time, not just attempts (pair with AbortSignal.timeout()).
  • Respect Retry-After headers when the server sends them.
  • Log the final failure with the attempt count. Silent retries hide outages.
📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.