Skip to main content

Harshal V. LADHE

Exponential Backoff with Jitter

Prevent server traffic spikes using random retry jitter.
Read the full post: Mastering Asynchronous JavaScript
Published at:
Last updated:
Estimated reading time:4 min read

Introduction

This utility is an evolution of the Exponential Backoff pattern. While standard backoff helps, synchronized retries from thousands of clients can create a "Thundering Herd" effect—crashing a server just as it attempts to recover.

Adding Jitter (randomness) spreads these requests out, ensuring a smoother recovery for your backend.

Exponential backoff dramatically improves reliability by spacing out retry attempts. However, in distributed systems another problem quickly appears.

Imagine 10,000 clients calling the same API.

If that API suddenly goes offline, every client begins retrying using the exact same delay schedule:

1 second
2 seconds
4 seconds
8 seconds

When the service comes back online, every client retries simultaneously.

Instead of recovering, the server immediately receives another massive wave of requests.

This phenomenon is known as the Thundering Herd Problem.

To solve this, modern distributed systems introduce Jitter—a small amount of randomness added to every retry delay.

Rather than retrying together, clients naturally spread themselves across time, allowing the recovering service to handle requests gradually instead of all at once.

What is Full Jitter?

Instead of always waiting exactly 1000ms, the client waits for a random duration between 0ms and 1000ms. For the next retry, it waits between 0ms and 2000ms, and then between 0ms and 4000ms.

Every client therefore receives a different retry schedule.

Even though the maximum delay still grows exponentially, the actual retry times become naturally distributed.

Why This Matters

Consider 1,000 users refreshing a page at the same time.

Without jitter:

████████████████████
████████████████████
████████████████████

Every retry lands together.

With Full Jitter:

██  █ █   ██ █
███   ███
  █ ███ █

Traffic becomes evenly spread across time.

This significantly reduces contention and allows recovering systems to stabilize much more quickly.

The Jitter Utility

/**
 * Retries an async function with Full Jitter.
 * @param {Function} fn - The async function to retry.
 * @param {Object} options - Configuration settings.
 * @param {number} options.retries - Max number of retry attempts.
 * @param {number} options.delay - Initial delay in ms.
 * @param {number} options.maxDelay - Maximum allowable delay cap.
 */
async function retryWithJitter(fn, { retries = 3, delay = 1000, maxDelay = 30000 } = {}) {
  try {
    return await fn();
  } catch (e) {
    if (retries === 0) throw e;

    // 1. Calculate the current upper backoff ceiling, bounded by maxDelay
    const currentRange = Math.min(delay, maxDelay);

    // 2. Full Jitter: Pick a uniform random duration between 0 and currentCeiling
    const jitteredDelay = Math.random() * currentRange;

    console.log(`Retrying in ${Math.round(jitteredDelay)}ms...`);

    await new Promise(resolve => setTimeout(resolve, jitteredDelay));

    // 3. Double base ceiling for the subsequent retry attempt
    return retryWithJitter(fn, {
      retries: retries - 1,
      delay: delay * 2,
      maxDelay
    });
  }
}

Typed Signature

The interactive sandbox below wires this up in TypeScript with a generic return type, a jitter toggle so you can compare it against plain exponential backoff, and an onWait callback for plotting each retry — here's the shape it types against:

export interface RetryOptions {
  retries?: number;
  delay?: number;
  maxDelay?: number;
  /** Set false to fall back to plain (non-jittered) exponential backoff */
  jitter?: boolean;
  /** Optional callback executed on every sleep cycle with the calculated wait in ms */
  onWait?: (ms: number) => void;
  /** Optional prefix for console output, useful when several callers retry concurrently */
  label?: string;
}

export function retryWithJitter<T>(
  fn: () => Promise<T>,
  options?: RetryOptions
): Promise<T>;

Why Use Jitter?

AttributeStandard Exponential BackoffFull Jitter BackoffEqual Jitter Backoff
Formulat = min(delay·2ⁿ, maxDelay)t = random(0, min(delay·2ⁿ, maxDelay))t = v÷2 + random(0, v÷2), where v is the Standard Backoff value
Timing PatternFully DeterministicRandom uniform distributionBase fallback + random component
Cluster BehaviorHigh peak concurrency spikesCompletely smooth load distributionBounded variation
Best Used ForIsolated background jobs / CLI scriptsDistributed microservices / APIsLatency-sensitive critical loops

How It Works

  1. Wait Calculation: Instead of waiting exactly N ms, we pick a random number between 0 and N.
  2. Decoherence: This ensures that multiple failing clients do not retry at the same time.
  3. Efficiency: "Full Jitter" is widely considered the most effective way to reduce contention on a recovering resource.

Example Usage

Distributed Batch API Request Handling

import { retryWithJitter } from "./retryWithJitter";

const syncDatabaseRecord = async (record) => {
  const res = await fetch(`/api/v1/records/${record.id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(record)
  });

  if (!res.ok) {
    throw new Error(`Database update rejected with status ${res.status}`);
  }

  return res.json();
};

// Safe execution across hundreds of simultaneous background sync jobs
async function processQueue(batch) {
  const updates = batch.map(record =>
    retryWithJitter(() => syncDatabaseRecord(record), {
      retries: 5,
      delay: 200,   // Low base delay for rapid initial checks
      maxDelay: 8000 // Upper ceiling caps wait times at 8s
    })
  );

  return Promise.allSettled(updates);
}

Below is a live comparison — six clients retry the same failing request at once. Toggle Full Jitter on and off and watch whether their retries land together or scatter across the timeline. The sandbox uses a shorter delay/maxDelay than the snippet above (400ms/4000ms instead of 1000ms/30000ms) purely so the timeline finishes animating in a few seconds — the retry logic is identical either way.

Jitter vs. No Jitter

Six concurrent clients retry a failing request — toggle Full Jitter to see retries scatter instead of stacking.

import { useRef, useState } from "react";
import { retryWithJitter, createFlakyCall } from "./retryWithJitter";
import type { RetryEvent } from "./types";
import "./shared/playground.css";
import "./styles.css";

const CLIENT_COUNT = 6;
const TIMELINE_MS = 4000;

export default function App() {
  const [jitter, setJitter] = useState(true);
  const [events, setEvents] = useState<RetryEvent[]>([]);
  const [running, setRunning] = useState(false);
  const startRef = useRef(0);

  async function run() {
    setEvents([]);
    setRunning(true);
    startRef.current = performance.now();

    await Promise.all(
      Array.from({ length: CLIENT_COUNT }, (_, client) => {
        const label = `Client ${client + 1}`;

        return retryWithJitter(createFlakyCall(label), {
          retries: 3,
          delay: 400,
          maxDelay: 4000,
          jitter,
          label,
          onWait: () => {
            const elapsedMs = performance.now() - startRef.current;
            setEvents((prev) => [...prev, { client, elapsedMs }]);
          },
        })
          .then(() => console.log(`${label}: succeeded`))
          .catch(() => console.error(`${label}: failed after all retries`));
      })
    );

    setRunning(false);
  }

  return (
    <main className="playground">
      <header className="playground-header">
        <h1>Exponential Backoff: Jitter vs. No Jitter</h1>
        <p>
          {CLIENT_COUNT} clients hit the same failing request at once. Watch where their retries land on the
          timeline below.
        </p>
      </header>

      <section className="demo-area" aria-label="Retry timeline">
        <div className="timeline">
          {Array.from({ length: CLIENT_COUNT }, (_, client) => (
            <div className="timeline-row" key={client}>
              <span className="timeline-label">Client {client + 1}</span>
              <div className="timeline-track">
                {events
                  .filter((e) => e.client === client)
                  .map((e, i) => (
                    <span
                      key={i}
                      className="timeline-mark"
                      style={{ left: `${Math.min((e.elapsedMs / TIMELINE_MS) * 100, 100)}%` }}
                    />
                  ))}
              </div>
            </div>
          ))}
        </div>
      </section>

      <fieldset className="control-group">
        <legend>Simulate</legend>
        <label className="checkbox-field">
          <input type="checkbox" checked={jitter} onChange={(e) => setJitter(e.target.checked)} disabled={running} />
          <span>Full Jitter enabled</span>
        </label>
        <button className="toggle-btn" onClick={run} disabled={running}>
          {running ? "Retrying…" : "Simulate Concurrent Clients"}
        </button>
      </fieldset>

      <p className="tip">
        {jitter
          ? "Marks scatter — retries spread out over time, easing load on the server."
          : "Marks stack in vertical columns — every client retries at the exact same moment."}
      </p>
    </main>
  );
}

Read-only
Ln , Col TypeScript React3.0 KBUTF-8

Starting sandbox…

No console output yet.

Where Should You Use Jitter?

Full Jitter is especially valuable for:

  • Public APIs
  • Microservices
  • Serverless platforms
  • Queue workers
  • Background jobs
  • Message consumers
  • Kubernetes workloads
  • Distributed schedulers
  • High-traffic SaaS platforms
  • Mobile applications with millions of users

For a simple CLI tool or internal script, ordinary exponential backoff is usually sufficient.

Production Considerations & Edge Cases

Jitter smooths out when retries happen, but it doesn't change the underlying risks of retrying at all — the same considerations from the base retry utility still apply here: retried functions need to be idempotent, long backoff sequences should support cancellation (e.g. via AbortSignal), and console.warn should be replaced with a structured logger in production.

Two jitter-specific things worth deciding upfront:

  • Full Jitter vs. Equal Jitter: Full Jitter (used above) gives the smoothest load distribution but means any individual retry could fire almost immediately. If a request needs a minimum backoff — e.g. to respect a downstream rate limit — Equal Jitter (see the comparison table above) guarantees at least half the backoff value while still spreading retries out.
  • Per-client jitter seeding: Math.random() is fine for spreading load across independent clients/processes, as here. It isn't a substitute for actual rate limiting on the server side — jitter reduces contention, it doesn't enforce a limit.

Key Takeaway

For production-scale applications, always favor Jitter. It transforms a fragile retry loop into a robust recovery mechanism that respects the health of your entire system architecture.

For the bigger picture this utility fits into — the event loop, promise internals, and the combinators (Promise.all, allSettled, race, any) that pair well with retries like this one — see the full Mastering Asynchronous JavaScript guide.

Categories:JavaScript
Tags:

Changelog

  • Initial publication with Full Jitter implementation.
This snippet is licensed under CC BY 4.0 by the author.

Share this snippet