Skip to main content

Harshal V. LADHE

Exponential Backoff with Jitter

Prevent server traffic spikes using random retry jitter.
Published at:
Last updated:
Estimated reading time:3 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 and an onWait callback for plotting each retry — here's the shape it types against:

export interface JitterOptions {
  retries?: number;
  delay?: number;
  maxDelay?: number;
  /** Optional callback executed on every sleep cycle with calculated ms */
  onWait?: (ms: number, attempt: number) => void;
}

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

Why Use Jitter?

AttributeStandard Exponential BackoffFull Jitter BackoffEqual Jitter Backoff
Formulat = minimum of (delay * 2^n, maxDelay)t = random value between 0 and maxDelayt = (v / 2) + random value between 0 and (v / 2)
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.

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 "./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 (
    <>
      <div className="wrapper">
        <h1 className="title">Exponential Backoff: Jitter vs. No Jitter</h1>
        <p className="subtitle">
          {CLIENT_COUNT} clients hit the same failing request at once. Watch where their retries land on the
          timeline below.
        </p>

        <label className="toggle">
          <input type="checkbox" checked={jitter} onChange={(e) => setJitter(e.target.checked)} disabled={running} />
          Full Jitter enabled
        </label>

        <button className="run-btn" onClick={run} disabled={running}>
          {running ? "Retrying…" : "Simulate Concurrent Clients"}
        </button>

        <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>

        <p className="hint">
          {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>
      </div>
    </>
  );
}

Ln , Col

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.

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.

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