ReliabilityApril 18, 20267 min read

Exponential Backoff and Jitter for Webhook Retries

Why naive exponential backoff causes thundering herds, how jitter fixes it, and what production-grade retry schedules actually look like.

ATTEMPT SPACING · EXPONENTIALnow5m30m2h12hgive upstatus=failed · payload still in the log · still replayable
A retry schedule that widens on every attempt. The last attempt is not the last chance: an exhausted event stays in the log and stays replayable.

TL;DR

  • Naive exponential backoff (delay = base * 2^attempt) creates thundering herds, because every client retries simultaneously
  • Add jitter (randomness) to spread retries out. Equal jitter (base/2 + random(base/2)) is the sweet spot
  • Cap max delay, cap total retry duration, and separate 4xx (don't retry) from 5xx (do retry)
  • Circuit-break at the destination level after roughly 20 consecutive failures. Retries won't fix a broken endpoint
  • Cheap replay beats aggressive retry: if replaying is one click, you only need retries to handle transient failures

Most retry code I see in the wild looks like this:

for (let i = 0; i < maxRetries; i++) {
  try {
    return await deliver(event);
  } catch {
    await sleep(1000 * Math.pow(2, i));  // 1s, 2s, 4s, 8s...
  }
}

This is exponential backoff, and it's better than constant-interval retry. It's also wrong in a way that only shows up in production, when hundreds of webhooks fail simultaneously and all retry at the same moment, slamming your recovering server back into the ground.

The fix is jitter. Here's why, and how to do it properly.

The thundering herd

Imagine your endpoint goes down at T+0. In the next 60 seconds, 500 Stripe webhooks arrive and all fail. Every retry queue now has 500 first retries scheduled at T+5min (or whatever your base delay is), 500 second retries at T+10min, and so on.

Your endpoint comes back online at T+4min. Congratulations: you now get 500 concurrent retries hitting at T+5min, pushing your still-recovering server back over its knee. That's the thundering herd.

NO JITTER · EVERY RETRY PICKS THE SAME INSTANT500 retries, one millisecondEQUAL JITTER · SAME COUNT, SAME CURVEspread across the windowT+0T+5minT+10min
Same 500 retries, same backoff curve. The only difference is whether the delay is a fixed number or a range.

Deterministic backoff has this problem by design. Every client or event that failed at the same time retries at the same time.

Jitter

Jitter adds randomness to retry timing so events retry on a spread instead of a spike. Two common strategies:

Full jitter

const base = 1000 * Math.pow(2, attempt);  // 1s, 2s, 4s, 8s
const sleep = Math.random() * base;         // 0 to base

Each retry waits anywhere from 0 to the full backoff interval. Maximal spread, but some retries fire almost immediately, which can feel inefficient.

Equal jitter

const base = 1000 * Math.pow(2, attempt);
const sleep = base / 2 + Math.random() * (base / 2);

Each retry waits between half and the full backoff. Bounded minimum, still good spread. This is what AWS recommends.

Decorrelated jitter

let sleep = baseDelay;
// on each retry:
sleep = Math.min(maxDelay, Math.random() * sleep * 3);

Each retry's delay is a random multiple of the previous one. Good when you want the backoff to grow without a fixed formula.

For webhooks, equal jitter is the sweet spot. It keeps retries roughly exponential while defusing the herd.

What production-grade retry schedules look like

Past the jitter, here's what experienced teams do.

Cap the maximum delay. Don't let attempt 10 wait 17 minutes. Set a ceiling, say 60s, and past the cap just stay there until you give up.

Cap total retry duration, not just attempt count. "Retry for 24 hours" is a clearer SLA than "retry 8 times," because the second one depends on delay math nobody remembers.

Separate transient from terminal errors. A 4xx from the destination, especially 401, 403, or 404, won't succeed on retry. The server is telling you the request is wrong. Only retry on 5xx, timeouts, and connection errors.

Surface the retry state. Your users need to know that an event is on attempt 3 of 5 with the next retry at a specific time. If it's invisible, debugging is painful.

Circuit break at the destination level. If a destination has failed 20 times in a row, stop retrying it for a while. Something is wrong that retries won't fix, and you're just burning compute.

How AnyHook handles this

AnyHook's retry policy is tuned per plan and uses QStash's managed retry infrastructure underneath. Retry counts are 3 on Free, 5 on Pro, and 10 on Scale, with exponential backoff and jitter applied by the queue. Timeout ceilings are 60s, 120s, and 5 minutes respectively. After 20 consecutive failures the circuit breaker pauses delivery to that destination and emails you, so there are no retry storms. And we alert on 4xx specifically, because retries won't help there and you need to fix the request shape.

Exhausted retries don't lose the event. It's persisted in your log with status=failed, and you can replay it manually from the dashboard or the API at any time within your plan's retention window (3 days on Free, 30 on Pro, 90 on Scale).

The cheat code

The insight that changes how you think about retries: you don't need aggressive retries if you have cheap replay.

If replaying is one click, retrying 3 times over an hour is enough. You catch most transient failures automatically, and for the rare long outage a human replays the failed batch once the server is healthy.

Compare that to retry-only systems, where you extend the retry window to 72 hours just in case, which means 72 hours of wasted deliveries to an endpoint you already know is down.

Cheap replay is strictly better than aggressive retry, and it's what AnyHook is built around.

Takeaway

Treat retries as a fast path for transient failures, not a substitute for persistence and replay. The systems that stay up during bad weeks are the ones where a failed delivery is always recoverable, because the event was logged before it was ever delivered.

Every sender's actual schedule is in our webhook provider reference. Retries also have a hard ceiling nobody controls: keep failing and Shopify deletes the subscription while Stripe disables the endpoint. And retries are precisely why duplicates and out-of-order delivery are guaranteed rather than rare: You Received the Same Webhook Twice.

All postsApril 18, 2026 · 7 min

Stop losing webhooks.

Change one URL. Get retries, event log, and one-click replay.