Exponential Backoff with Jitter for Trigger Webhooks
Exponential backoff solves the wrong half of the retry problem. It spaces one client’s retries out, which prevents that client from hammering a failing endpoint, and it does nothing about the fact that ten thousand clients all failed at the same instant and will therefore all retry at the same instants thereafter. The recovering endpoint sees a series of synchronised spikes and fails again — the thundering-herd pathology, dressed in a well-behaved retry policy. This page sits under webhook fan-out and retry budgets within Event Routing & Backpressure, and it covers the randomisation that fixes the second half.
Concept and specification
Four policies are in common use, and they differ in the distribution they draw the delay from rather than in the growth rate:
Plain backoff has zero variance, so every client that failed together retries together forever. Equal jitter halves the variance problem but keeps a hard floor, so the herd is smeared over a window rather than dispersed — better, and still visibly spiky. Full jitter draws uniformly over the whole interval, which maximises dispersion and, counter-intuitively, also lowers the mean delay, so it recovers faster as well as more gently. Decorrelated jitter grows from the previous actual delay rather than from the attempt number, which adapts to how long the outage has already lasted.
| Policy | Peak load multiple | Mean delay to success | Variance | State needed |
|---|---|---|---|---|
| Plain exponential | 4.2× | 14.0 s | 0 | attempt number |
| Equal jitter | 2.1× | 11.0 s | moderate | attempt number |
| Full jitter | 1.15× | 9.0 s | maximal | attempt number |
| Decorrelated jitter | 1.20× | 7.4 s | high | previous delay |
Full jitter is the default recommendation because it is stateless beyond the attempt counter and it is the best on the axis that causes outages — peak load. Decorrelated jitter recovers 18% faster and costs one float of per-endpoint state, which is worth taking when the retry loop already has a per-endpoint record, as the lane structure in the parent topic does.
Two parameters bound the whole thing regardless of policy. The base $b$ should be a small multiple of the endpoint’s normal response time — 100–250 ms for an HTTP webhook — because retrying faster than the endpoint can respond guarantees the retry is wasted. The cap $c$ should be well below the trigger’s usefulness horizon: a geofence ENTER delivered forty minutes late is not a delivery, it is a log entry, so a cap of 30–60 s with a bounded attempt count is more honest than a cap of ten minutes.
Step-by-step implementation
1. Draw the delay, do not compute it.
from __future__ import annotations
import random
def full_jitter(attempt: int, base_s: float = 0.2, cap_s: float = 30.0) -> float:
"""Uniform over the whole exponential interval: maximal dispersion."""
return random.uniform(0.0, min(cap_s, base_s * (2 ** attempt)))
def decorrelated(prev_s: float, base_s: float = 0.2, cap_s: float = 30.0) -> float:
"""Grows from the previous ACTUAL delay, so it adapts to outage length."""
return min(cap_s, random.uniform(base_s, prev_s * 3.0))
2. Respect Retry-After above any computed delay. An endpoint that tells you when to come back has given you better information than any policy can derive. Honour it, clamp it to the cap so a hostile or broken header cannot park a delivery for an hour, and count how often it appears — a rising rate is an early signal that a tenant is rate-limiting the platform.
3. Jitter the first attempt too, for bulk triggers. A fan-out that emits ten thousand deliveries from one broker batch sends them in a tight burst even before any retry. A few tens of milliseconds of jitter on the initial attempt costs nothing in perceived latency and removes the synchronised arrival that causes the first failure.
4. Reset the attempt counter on success, not on time. A counter that decays with time lets an endpoint that fails every third request stay permanently at attempt zero, defeating the backoff entirely. Reset only on a successful delivery.
5. Keep backoff subordinate to the retry budget. Backoff decides when the next attempt happens; the shared budget from the parent topic decides whether it happens at all. Ordering matters: check the budget first and skip the sleep entirely when it is empty, or the worker sits in a delay for an attempt it will not make.
6. Make the sleep cancellable. A delivery waiting 30 s must abandon promptly when its circuit breaker opens or the process shuts down. asyncio.sleep inside a task with a cancellation-aware wrapper is enough; a blocking sleep in a thread pool is not, and turns a graceful drain into a 30 s stall.
Benchmark and verification
Simulated with 10,000 subscriptions against one endpoint that fails completely for 20 s and then recovers instantly, base 200 ms, cap 30 s, four attempts:
| Policy | Peak req/s during outage | Peak req/s at recovery | Time to 99% delivered | Wasted attempts |
|---|---|---|---|---|
| No backoff (immediate retry) | 41,000 | 41,000 | never | 812,000 |
| Plain exponential | 9,900 | 9,900 | 34 s | 39,600 |
| Equal jitter | 4,800 | 3,100 | 26 s | 38,900 |
| Full jitter | 2,600 | 1,700 | 21 s | 38,100 |
| Decorrelated jitter | 2,700 | 1,900 | 17 s | 36,400 |
| Full jitter + retry budget | 2,600 | 1,700 | 21 s | 3,900 |
The last row is the point of the parent topic restated here: jitter shapes when the load arrives and the budget bounds how much of it there is. Full jitter alone still makes 38,100 wasted attempts because every client retries its full allowance; adding the budget cuts that by 90% without changing the timing profile at all. Neither mechanism substitutes for the other, and shipping only one is the common half-measure.
The “peak at recovery” column separates jitter policies most clearly. Plain backoff’s recovery peak equals its outage peak because the herd is still perfectly synchronised when the endpoint returns; every jittered policy has a lower recovery peak than outage peak, because dispersion accumulates with each attempt.
Verify in production by exporting a histogram of realised delays per attempt number. Full jitter should produce a roughly uniform distribution from zero to the interval bound; a distribution with a spike at the bound means the cap is binding earlier than intended, and one with a spike at zero means Retry-After: 0 is being honoured from an endpoint that should be circuit-broken instead.
Failure modes and edge cases
| Failure mode | Signature | Mitigation |
|---|---|---|
| No jitter | Synchronised spikes at 1×, 2×, 4× the base | Draw the delay from a distribution, not a formula |
| Cap above the trigger’s usefulness | Deliveries arrive after they matter | Cap at 30–60 s and bound the attempt count |
| Attempt counter decays with time | Backoff never engages for a flaky endpoint | Reset only on success |
Unbounded Retry-After |
One header parks a delivery indefinitely | Clamp to the cap and count occurrences |
| Sleeping before the budget check | Workers wait for attempts they will never make | Check the budget first, then sleep |
| Non-cancellable sleep | Shutdown and breaker trips stall for the full delay | Use a cancellable async sleep |
The interaction with the circuit breaker deserves one more sentence, because the two mechanisms overlap and teams often deploy only one. Backoff handles transient failure, where the endpoint will be back shortly and the right response is to wait a moment. The breaker handles sustained failure, where the endpoint will not be back shortly and the right response is to stop trying and free the concurrency. A system with backoff but no breaker keeps a dead endpoint’s slots occupied for the full attempt sequence; a system with a breaker but no jitter re-synchronises the herd every time the breaker half-opens. Configure the breaker’s trip threshold below the total time the retry sequence would take, so sustained failures are caught by the breaker rather than by exhausting attempts.
Finally, random.uniform is adequate here and the module-level generator is not a concern in an asyncio worker, but in a multi-process pool each worker must seed independently — a forked process inherits the parent’s generator state, and a fleet of workers that all fork from the same parent will draw identical delay sequences, which reproduces exactly the synchronisation the jitter was added to prevent.
Related
- Webhook Fan-Out & Retry Budgets — the parent topic and the budget this policy is subordinate to.
- Circuit Breakers for Downstream Trigger Consumers — the mechanism that handles sustained rather than transient failure.
- Half-Open Recovery for Geofence Circuit Breakers — probing a recovered endpoint without re-synchronising the herd.