Solving the Thundering Herd Problem: Implementing API Retries with Exponential Backoff and Jitter

Arpit Bhayani

Arpit Bhayani

Feb 05, 2023 • 6 min read

Play

Solving the Thundering Herd Problem: Implementing API Retries with Exponential Backoff and Jitter

In distributed systems, networks are inherently unreliable. Transient failures—such as dropped TCP packets, temporary network partitions, DNS blips, or fleeting server hiccups—are inevitable. When a client encounters an intermittent failure, the standard engineering response is to retry the request.

However, a retry mechanism implemented incorrectly can quickly degrade system stability. Under high concurrency, retries often trigger the Thundering Herd Problem, escalating a transient issue into a total cascading outage.


The Anatomy of an API Retry

When a client makes a network call to a backend service and encounters a transient failure, retrying ensures completion without requiring manual user intervention.

The Idempotency Prerequisite

Before retrying any request, the operation should ideally be idempotent. An idempotent operation produces the same result whether executed once or multiple times. Retrying non-idempotent operations (such as raw POST /charge-credit-card requests) without an idempotency key risks duplicate state mutations (such as double charging a customer).

sequenceDiagram
    autonumber
    participant Client
    participant Backend
    Client->>Backend: Request (Attempt 1)
    Note over Backend: Network glitch / CPU spike
    Backend--xClient: 503 Service Unavailable / Connection Reset
    Client->>Backend: Request (Attempt 2)
    Backend-->>Client: 200 OK

Anti-Pattern 1: The Naive Immediate Retry

A common but dangerous approach is to retry failed requests immediately in a tight loop:

def call_api_with_naive_retry(max_retries=3):
    for attempt in range(max_retries):
        try:
            return make_network_call()
        except TransientNetworkError:
            if attempt == max_retries - 1:
                raise

Why This Fails at Scale

  1. Few Clients: If there are only a handful of clients, immediate retries are largely harmless.
  2. Millions of Clients: Suppose the backend experiences a brief spike in traffic or CPU utilization, causing 10% of requests across millions of active clients to fail.
  3. Amplification Effect: Instead of dropping off, every failed client immediately sends another request back-to-back.
  4. Cascading Outage: The backend, already struggling under load, receives the regular stream of new traffic plus a massive wave of immediate retries. The server never gets the idle cycles needed to flush queues, release database locks, or scale out. A minor transient spike turns into a prolonged site-wide outage.

Anti-Pattern 2: Fixed or Pure Exponential Backoff

To prevent hammering the server immediately, standard practice introduces a pause—a backoff period—between retry attempts. The most common formulation is Exponential Backoff, where the wait interval increases geometrically with each failure:

Delay=Bn\text{Delay} = B^n

Where BB is the base factor (commonly 2) and nn is the retry attempt index:

  • Attempt 1: Wait 11 second
  • Attempt 2: Wait 22 seconds
  • Attempt 3: Wait 44 seconds
  • Attempt 4: Wait 88 seconds
  • Attempt 5: Wait 1616 seconds

This pattern is common in user-facing applications (e.g., Slack or Gmail displaying “Disconnected. Retrying in 2s… 4s… 8s…”).

The Coincidence Problem: Periodic Thundering Herds

Pure exponential backoff works well if failures are randomly dispersed. However, in major distributed systems, failures often occur in correlated waves (e.g., an entire microservice pod crashes or a network switch resets at time TT).

graph TD
    subgraph Synchronized Spikes
        T0["T=0: Network Drop (100k clients fail)"] -->|Wait 1s| T1["T=1s: 100k retries hit simultaneously"]
        T1 -->|Wait 2s| T3["T=3s: 80k retries hit simultaneously"]
        T3 -->|Wait 4s| T7["T=7s: 60k retries hit simultaneously"]
    end

If 100,000 clients fail simultaneously at time TT:

  • All 100,000 clients retry in lockstep at T+1T + 1.
  • If the server cannot handle that synchronized spike, all 100,000 fail again.
  • All 100,000 clients retry together again at T+3T + 3 (1+21 + 2).
  • They retry together again at T+7T + 7 (1+2+41 + 2 + 4).

Even though the retries are spaced out exponentially, they remain phase-locked. The backend encounters severe, synchronized traffic spikes at periodic intervals, preventing the system from stabilizing.


The Solution: Exponential Backoff with Jitter

To break the harmonic lockstep of synchronized clients, inject randomness—known in networking and distributed systems as Jitter.

Instead of waiting exactly 2n2^n seconds, each client introduces a randomized offset. This desynchronizes the retry timers and spreads the aggregate retry volume evenly across the time window.

graph LR
    A["Synchronized Failures at T=0"] --> B["Client 1 waits 1.2s"]
    A --> C["Client 2 waits 0.4s"]
    A --> D["Client 3 waits 1.9s"]
    A --> E["Client 4 waits 0.8s"]
    
    B --> F["Smooth, Distributed Traffic"]
    C --> F
    D --> F
    E --> F

Implementing Jitter

A standard approach is Full Jitter, where the actual wait duration is selected uniformly at random between 0 and the calculated exponential ceiling:

Sleep=random(0,min(M,Bn))\text{Sleep} = \text{random}(0, \min(M, B^n))

Where:

  • BB is the exponential base (typically 2).
  • nn is the retry attempt.
  • MM is a maximum backoff ceiling to prevent infinite wait times.

Production-Ready Implementation Example

import time
import random

def call_with_backoff_and_jitter(
    request_fn,
    max_retries: int = 5,
    base_delay: float = 1.0,
    max_delay: float = 32.0
):
    """
    Executes a callable with exponential backoff and randomized full jitter.
    """
    for attempt in range(max_retries):
        try:
            return request_fn()
        except TransientNetworkError as exc:
            if attempt == max_retries - 1:
                # Exhausted all attempts; re-raise exception
                raise exc
            
            # Calculate exponential upper bound: base * 2^attempt
            exponential_ceiling = min(max_delay, base_delay * (2 ** attempt))
            
            # Full jitter: uniformly random between 0 and ceiling
            sleep_duration = random.uniform(0, exponential_ceiling)
            
            time.sleep(sleep_duration)

Summary of Retry Strategies

StrategyDelay BehaviorRisk at ScaleIdeal Use CaseOverload Characteristic
Immediate Retry00 (None)Severe: Multiplies traffic immediately.Local in-memory retries, low-traffic dev tools.Instant catastrophic collapse.
Fixed DelayConstant CCHigh: Retries arrive in continuous locked pulses.Non-critical background worker jobs.Sustained periodic pulses.
Pure Exponential2n2^nModerate to High: Retries still cluster on common intervals.Scenarios with few, independent clients.Spaced harmonic spikes.
Exponential + Jitterrand(0,2n)\text{rand}(0, 2^n)Low: Breaks synchronization; flattens peaks into a smooth distribution.High-scale, distributed production systems.Flat, manageable background load.

Key Architectural Takeaways

  1. Assume Failure Happens in Batches: Outages rarely hit one client in isolation. A database slow-query spike or an upstream load balancer failure impacts thousands of requests at the exact same instant.
  2. Break Synchronization with Jitter: Exponential backoff provides spacing, but jitter provides desynchronization. You must use both.
  3. Cap the Maximum Backoff: Always define an upper bound (MM) so backoff timers do not balloon into minutes or hours.
  4. Enforce Hard Retry Limits: Set a reasonable maximum number of retries (typically 3 to 5) before failing fast or falling back to a degraded state.
  5. Combine with Circuit Breakers: If an upstream service is completely down, retrying—even with jitter—wastes client threads and connection pools. Pair jittered retries with client-side circuit breakers to fast-fail traffic while the dependency is unavailable.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses