How to retry failed webhook deliveries using exponential backoff, jitter, and dead letter queues.
Last updated March 2026. Production-tested patterns used by Stripe, GitHub, and SDNBros.
Webhooks fail. Not sometimes — regularly. Network blips, server restarts, deployment windows, rate limits, and DNS hiccups all cause delivery failures. Without a retry strategy, those events are lost forever.
429 Too Many Requests when you send too fastA good retry strategy turns a 95% delivery rate into 99.99%. Here are four approaches, from simplest to best.
The simplest approach — retry every N seconds. Wait 60 seconds, try again. Wait 60 seconds, try again.
// Fixed interval — simple but problematic
const RETRY_INTERVAL = 60_000; // 60 seconds
const MAX_RETRIES = 5;
async function deliverWithFixedRetry(url, payload, attempt = 1) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
} catch (err) {
if (attempt >= MAX_RETRIES) throw err;
await sleep(RETRY_INTERVAL);
return deliverWithFixedRetry(url, payload, attempt + 1);
}
}
Problem: thundering herd. If 10,000 webhooks fail at the same time (e.g. a destination goes down), they all retry at exactly the same moment — 60 seconds later. Then again at 120 seconds. The destination gets hammered with synchronized bursts, making recovery harder.
Double the delay between each retry. This gives the destination progressively more time to recover.
Much better than fixed intervals — early retries catch transient blips quickly, while later retries back off for longer outages. But it still has the thundering herd problem if many webhooks fail simultaneously.
Add a random offset (jitter) to each delay. This spreads retries across time, eliminating synchronized bursts.
// Exponential backoff with jitter — the production-ready approach
async function deliverWithBackoff(url, payload, attempt = 1, maxRetries = 5) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.status === 429) {
// Respect Retry-After header if present
const retryAfter = res.headers.get('Retry-After');
if (retryAfter) {
await sleep(parseInt(retryAfter, 10) * 1000);
return deliverWithBackoff(url, payload, attempt, maxRetries);
}
}
if (!res.ok) throw new Error('HTTP ' + res.status);
} catch (err) {
if (attempt >= maxRetries) throw err;
const baseDelay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s, 16s...
const jitter = baseDelay * 0.2 * Math.random(); // 0-20% random jitter
await sleep(baseDelay + jitter);
return deliverWithBackoff(url, payload, attempt + 1, maxRetries);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
This is the pattern used by AWS, Google Cloud, and most production webhook systems. The jitter prevents correlated retries from overwhelming the destination during recovery.
What happens after all retries fail? The event should never be silently dropped. Send it to a dead letter queue (DLQ) for manual review, alerting, or later replay.
async function deliverWebhook(event) {
try {
await deliverWithBackoff(event.url, event.payload);
await markDelivered(event.id);
} catch (err) {
// All retries exhausted — move to dead letter queue
await sendToDeadLetterQueue({
eventId: event.id,
url: event.url,
payload: event.payload,
lastError: err.message,
failedAt: new Date().toISOString(),
attempts: event.maxRetries,
});
await alertOpsTeam(event); // Slack notification, PagerDuty, etc.
}
}
A DLQ gives you a safety net. When the destination comes back online, you can replay failed events from the DLQ — either manually or automatically. No data is lost.
| Property | Fixed | Exponential | Exponential + Jitter |
|---|---|---|---|
| Thundering herd | Yes — all retries synchronized | Reduced but still correlated | Eliminated by randomization |
| Fast recovery | Depends on interval chosen | Yes — first retry is fast (1-2s) | Yes — first retry is fast with slight jitter |
| Server load during outage | High — constant retry pressure | Moderate — decreasing over time | Low — spread across time |
| Implementation complexity | Trivial | Simple | Simple (a few extra lines) |
| Production readiness | Not recommended | Acceptable for low volume | Industry standard |
SDNBros uses exponential backoff with jitter across 5 retry attempts, spaced to cover both transient issues and extended outages:
| Attempt | Base Delay | With Jitter (0-20%) | Cumulative Wait |
|---|---|---|---|
| Retry 1 | 1 minute | 1m 0s - 1m 12s | ~1 minute |
| Retry 2 | 5 minutes | 5m 0s - 6m 0s | ~6 minutes |
| Retry 3 | 30 minutes | 30m 0s - 36m 0s | ~36 minutes |
| Retry 4 | 2 hours | 2h 0m - 2h 24m | ~2.5 hours |
| Retry 5 | 12 hours | 12h 0m - 14h 24m | ~15 hours |
After 5 failed attempts, the event moves to a dead letter queue. You can replay it from the SDNBros dashboard at any time, or set up automatic replay when the destination comes back online.
SDNBros manages retry logic, dead letter queues, and delivery monitoring so you don't have to build it yourself. Focus on your webhook handler — we handle the infrastructure.
Start Free Try Webhook TesterRelated guides: What is a Webhook? · Webhook vs API · Webhook Security
More: What is a Webhook? | Webhook Security Guide | Stripe Webhooks | Slack Webhooks