Webhook Retry Strategies

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.

Why Retries Matter

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.

A good retry strategy turns a 95% delivery rate into 99.99%. Here are four approaches, from simplest to best.

1

Strategy: Fixed Interval

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.

2

Strategy: Exponential Backoff

Double the delay between each retry. This gives the destination progressively more time to recover.

Attempt 1: wait 1s   then retry
Attempt 2: wait 2s   then retry
Attempt 3: wait 4s   then retry
Attempt 4: wait 8s   then retry
Attempt 5: wait 16s  then retry
Attempt 6: wait 32s  then retry
           total: ~63s across 6 attempts

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.

3

Strategy: Exponential Backoff + Jitter (The Right Answer)

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.

4

Strategy: Dead Letter Queue

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.

Strategy Comparison

PropertyFixedExponentialExponential + Jitter
Thundering herdYes — all retries synchronizedReduced but still correlatedEliminated by randomization
Fast recoveryDepends on interval chosenYes — first retry is fast (1-2s)Yes — first retry is fast with slight jitter
Server load during outageHigh — constant retry pressureModerate — decreasing over timeLow — spread across time
Implementation complexityTrivialSimpleSimple (a few extra lines)
Production readinessNot recommendedAcceptable for low volumeIndustry standard

How SDNBros Handles Retries

SDNBros uses exponential backoff with jitter across 5 retry attempts, spaced to cover both transient issues and extended outages:

AttemptBase DelayWith Jitter (0-20%)Cumulative Wait
Retry 11 minute1m 0s - 1m 12s~1 minute
Retry 25 minutes5m 0s - 6m 0s~6 minutes
Retry 330 minutes30m 0s - 36m 0s~36 minutes
Retry 42 hours2h 0m - 2h 24m~2.5 hours
Retry 512 hours12h 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.

Let us handle retries for you

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 Tester

Related guides: What is a Webhook? · Webhook vs API · Webhook Security

More: What is a Webhook? | Webhook Security Guide | Stripe Webhooks | Slack Webhooks