jonnifyredis
Redis Patterns / R4 · Time, Delay & Priority / Backoff & retry / Jitter & the thundering herd

R4.04.2 · dive 2

Jitter & the thundering herd

When many jobs fail at once, exponential backoff makes them re-fire in one synchronized spike. Jitter spreads each delay across a band, so the herd disperses.

A dependency goes down and takes a batch of jobs with it. Each runs the same formula with the same inputs, so each computes the same delay and re-fires at the same future millisecond — a spike that hits the recovering dependency at once. Jitter adds controlled randomness so the retries scatter across a window. Toggle the switch on the right to fire a batch with jitter off (the spike) and on (the spread), and read the spread width.

A failed batch re-fires · jitter off vs on

4000ms 12 jobs, 4000ms delay each — where each retry lands —

The thundering herd

A dependency goes down. Every job that needed it fails in the same window — say a thousand jobs failing within the same second. Each runs the same exponential formula with the same base and the same attempt number, so each computes the same delay. They all re-schedule for the same future millisecond. When that millisecond arrives, all thousand re-fire together.

The dependency, perhaps newly back on its feet, takes the full thousand-job spike in one instant and may fail again — which schedules a thousand more retries for the next backoff window, and the spike repeats. Backoff was meant to space the load out; without jitter it re-synchronizes it. The herd is the cost of every member computing an identical delay from identical inputs.

Exponential backoff alone re-synchronizes a batch that failed together: identical inputs produce an identical delay, so the whole batch re-fires on one millisecond — the thundering herd.

Jitter spreads the window

The fix is to add controlled randomness to each delay so the retries land across a window instead of on a point. EchoMQ.Backoff applies it in apply_jitter/2. For a jitter fraction in (0, 1] it widens the delay into a band.

# EchoMQ.Backoff.apply_jitter/2 — the jitter clause (real)
defp apply_jitter(delay, jitter) when jitter > 0 and jitter <= 1 do
  min_delay    = trunc(delay * (1 - jitter))
  jitter_range = trunc(delay * jitter * 2)
  min_delay + :rand.uniform(jitter_range + 1) - 1
end

min_delay is the floor — the computed delay reduced by the jitter fraction. jitter_range spans twice the fraction, so the band runs from delay × (1 - jitter) up to delay × (1 + jitter). :rand.uniform picks a point inside it, independently for each job. With jitter: 0.2 on a 4000 ms delay, min_delay is 3200, jitter_range is 1600, and each retry lands somewhere in [3200, 4800] — the computed delay plus or minus twenty percent. A thousand jobs that would have re-fired on one millisecond now scatter across a 1600 ms window, a few per millisecond instead of all at once. The interactive below sweeps the jitter fraction and reads the band it produces.

The band · apply_jitter on a 4000ms delay

0.20
4000ms min max delay band = [4000 × (1 - jitter), 4000 × (1 + jitter)]

Jitter widens a delay into the band [delay × (1 - jitter), delay × (1 + jitter)] and draws an independent point per job, so a batch that would re-fire on one millisecond scatters across the window instead.

Jitter applies to fixed backoff too

The herd is not exclusive to exponential schedules. A fixed backoff — the same base every attempt — has the same flaw: a batch that fails together re-fires together. EchoMQ.Backoff's :fixed clause routes through the same jitter: apply_jitter(base_delay, jitter). So a %{type: :fixed, delay: 5_000, jitter: 0.2} config spaces a batch's retries across [4000, 6000] exactly the way the exponential one does. Jitter is the herd-breaker; the strategy (fixed or exponential) only sets the center of the band, not whether it is spread.

Jitter is the herd-breaker, not the strategy: :fixed and :exponential both route through apply_jitter, so each sets the band's center while jitter sets its width.

In EchoMQ — the spread, in real code

The spread is real code in echo/apps/echomq. Both built-in strategies end in apply_jitter(delay, jitter): the :exponential clause computes trunc(:math.pow(2, attempt - 1) × base_delay) first, the :fixed clause passes the base straight through. The jitter clause then returns min_delay + :rand.uniform(jitter_range + 1) - 1. The doc example carries it: calculate(:exponential, 3, 1000, jitter: 0.2) returns about 4000 — about, not exactly, because each call draws a different point in the band. The default config sets jitter: 0.2, so the spread is on by default. A jitter of 0 (the apply_jitter(delay, 0) clause) returns the delay unchanged — that is the synchronized case, the herd.

# jitter: 0.2 on a 4000ms delay — the band (real arithmetic)
min_delay    = trunc(4000 * (1 - 0.2))   # => 3200
jitter_range = trunc(4000 * 0.2 * 2)     # => 1600
delay        = 3200 + :rand.uniform(1601) - 1  # => a point in [3200, 4800]
the pattern

A synchronized batch of retries re-fires as one spike; jitter scatters each delay across a window so the load spreads.

in EchoMQ

apply_jitter(delay, jitter) returns min_delay + :rand.uniform(jitter_range + 1) - 1, where min_delay = trunc(delay × (1 - jitter)) and jitter_range = trunc(delay × jitter × 2) — so jitter: 0.2 spreads a 4000 ms delay across [3200, 4800]. Each job draws its own point.

A fixed, exact delay re-synchronizes a failed batch into a spike; jitter widens each delay into a band and draws an independent point per job, so the herd disperses across a window instead of on one millisecond.

Where this is heading · EchoMQ 2.0 Jitter is computed in EchoMQ.Backoff before the delay is written, so the spread is decided in Elixir, not in the key. EchoMQ 2.0 renames the delayed key from emq:{queue}:delayed to emq:{queue}:delayed and records meta.version as echomq:2.0.0, but apply_jitter/2 is unchanged by the break — the band math runs over the delay value, not the keyspace. The herd-breaking this dive teaches works the same on either prefix.
Door · not depth How a fleet of workers stays clear of the herd at scale — the lifecycle that fails, backs off, jitters, and retries a job across a worker pool — is the subject of the dedicated EchoMQ course. The retry and checkpoint lifecycle is E6 · Lifecycle controls. This dive teaches the spread; that course teaches the lifecycle that uses it.

References

The write that places each jittered retry at its own fire-time, the sorted set the spread scatters across, and the queue protocol EchoMQ ports.

Sources

Related in this course