Exponential Backoff with Jitter
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 secondsWhen 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?
| Attribute | Standard Exponential Backoff | Full Jitter Backoff | Equal Jitter Backoff |
|---|---|---|---|
| Formula | t = minimum of (delay * 2^n, maxDelay) | t = random value between 0 and maxDelay | t = (v / 2) + random value between 0 and (v / 2) |
| Timing Pattern | Fully Deterministic | Random uniform distribution | Base fallback + random component |
| Cluster Behavior | High peak concurrency spikes | Completely smooth load distribution | Bounded variation |
| Best Used For | Isolated background jobs / CLI scripts | Distributed microservices / APIs | Latency-sensitive critical loops |
How It Works
- Wait Calculation: Instead of waiting exactly
Nms, we pick a random number between0andN. - Decoherence: This ensures that multiple failing clients do not retry at the same time.
- 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> </> ); }
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.
Changelog
- — Initial publication with Full Jitter implementation.