Retrying Failed Requests: Exponential Backoff Explained

TL;DR

Retry failed requests with increasing delays: 1s, 2s, 4s, 8s. Add jitter to prevent thundering herd. Stop after max retries. Exponential backoff handles transient failures and rate limits gracefully.

Our payment webhook failed. Stripe sent the payment confirmation, our API returned 500, Stripe never retried. Customer charged but order never fulfilled. Manual reconciliation for 50 orders.

I added retry logic with exponential backoff. Transient failures now retry automatically. Haven't had a lost webhook since. Five lines of code prevented a recurring nightmare.

Here's how to retry failed requests properly, with exponential backoff, jitter, and real production patterns.

Why Requests Fail

Network requests fail all the time:

// This will fail eventually
const response = await fetch('https://api.example.com/data');

Common failures:

  • Network timeout (WiFi drops, slow connection)
  • Server temporarily down (restart, deploy)
  • Rate limiting (429 Too Many Requests)
  • Transient errors (500 Internal Server Error)
  • Database connection issues (temporary)

One-time requests fail permanently. Retrying fixes most failures.

The Simplest Retry

async function fetchWithRetry(url, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            // Non-ok response, throw to retry
            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            // Last attempt, throw error
            if (i === maxRetries - 1) {
                throw error;
            }

            console.log(`Attempt ${i + 1} failed, retrying...`);
        }
    }
}

// Usage
try {
    const data = await fetchWithRetry('https://api.example.com/data', 3);
    console.log(data);
} catch (error) {
    console.error('All retries failed:', error);
}

Problem: Retries immediately. Hammers failing server.

Adding Delays

Wait between retries:

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchWithRetry(url, maxRetries = 3, delayMs = 1000) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            if (i === maxRetries - 1) {
                throw error;
            }

            console.log(`Attempt ${i + 1} failed, waiting ${delayMs}ms...`);
            await sleep(delayMs);
        }
    }
}

// Try 3 times with 1 second between attempts
const data = await fetchWithRetry('https://api.example.com/data', 3, 1000);

Problem: Fixed delay. All clients retry at same time (thundering herd).

Exponential Backoff

Double the delay after each retry:

async function fetchWithRetry(url, maxRetries = 5) {
    let delay = 1000; // Start with 1 second

    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            if (i === maxRetries - 1) {
                throw error;
            }

            console.log(`Attempt ${i + 1} failed, waiting ${delay}ms...`);
            await sleep(delay);

            delay *= 2; // Double the delay: 1s, 2s, 4s, 8s, 16s
        }
    }
}

// Retry with exponential backoff:
// Attempt 1: fail → wait 1s
// Attempt 2: fail → wait 2s
// Attempt 3: fail → wait 4s
// Attempt 4: fail → wait 8s
// Attempt 5: fail → throw error

Delays:

Attempt 1: 0s (immediate)
Attempt 2: 1s delay
Attempt 3: 2s delay
Attempt 4: 4s delay
Attempt 5: 8s delay
Total time: 15 seconds max

Adding Jitter

Randomize delays to prevent thundering herd:

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchWithRetry(url, maxRetries = 5) {
    let delay = 1000;

    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            if (i === maxRetries - 1) {
                throw error;
            }

            // Add jitter: random delay between 0 and calculated delay
            const jitter = Math.random() * delay;
            console.log(`Attempt ${i + 1} failed, waiting ${Math.round(jitter)}ms...`);
            await sleep(jitter);

            delay *= 2;
        }
    }
}

// With jitter:
// Attempt 2: 0-1000ms (random)
// Attempt 3: 0-2000ms (random)
// Attempt 4: 0-4000ms (random)
// Attempt 5: 0-8000ms (random)

Why jitter matters:

Without jitter:
Server crashes at 12:00:00
1000 clients all retry at 12:00:01 → server crashes again
1000 clients all retry at 12:00:03 → server crashes again

With jitter:
Server crashes at 12:00:00
1000 clients retry randomly between 12:00:01-12:00:02
Server recovers gradually

Full Implementation

class RetryableError extends Error {
    constructor(message, retryable = true) {
        super(message);
        this.retryable = retryable;
    }
}

async function fetchWithRetry(url, options = {}) {
    const {
        maxRetries = 5,
        initialDelay = 1000,
        maxDelay = 32000,
        factor = 2,
        onRetry = null
    } = options;

    let delay = initialDelay;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
            const response = await fetch(url);

            // Success
            if (response.ok) {
                return await response.json();
            }

            // Client errors (4xx) - don't retry
            if (response.status >= 400 && response.status < 500) {
                if (response.status === 429) {
                    // Rate limited - retry with Retry-After header
                    const retryAfter = response.headers.get('Retry-After');
                    if (retryAfter) {
                        delay = parseInt(retryAfter) * 1000;
                    }
                    throw new RetryableError(`Rate limited: ${response.status}`);
                }

                // Other 4xx - don't retry
                throw new RetryableError(
                    `Client error: ${response.status}`,
                    false
                );
            }

            // Server errors (5xx) - retry
            throw new RetryableError(`Server error: ${response.status}`);

        } catch (error) {
            const isLastAttempt = attempt === maxRetries - 1;

            // Don't retry if error is not retryable
            if (error instanceof RetryableError && !error.retryable) {
                throw error;
            }

            // Last attempt - throw error
            if (isLastAttempt) {
                throw error;
            }

            // Calculate delay with jitter
            const jitter = Math.random() * delay;
            const actualDelay = Math.min(jitter, maxDelay);

            // Call retry callback if provided
            if (onRetry) {
                onRetry(attempt + 1, actualDelay, error);
            }

            console.log(
                `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}. ` +
                `Retrying in ${Math.round(actualDelay)}ms...`
            );

            await sleep(actualDelay);
            delay = Math.min(delay * factor, maxDelay);
        }
    }
}

// Usage
try {
    const data = await fetchWithRetry('https://api.example.com/data', {
        maxRetries: 5,
        initialDelay: 1000,
        maxDelay: 32000,
        onRetry: (attempt, delay, error) => {
            console.log(`Retry attempt ${attempt} after ${delay}ms: ${error.message}`);
        }
    });
    console.log('Success:', data);
} catch (error) {
    console.error('Failed after retries:', error);
}

Retry Only Specific Errors

function isRetryable(error, response) {
    // Network errors - always retry
    if (error instanceof TypeError && error.message.includes('fetch')) {
        return true;
    }

    // No response - retry
    if (!response) {
        return true;
    }

    // Rate limiting - retry
    if (response.status === 429) {
        return true;
    }

    // Server errors - retry
    if (response.status >= 500) {
        return true;
    }

    // Service unavailable - retry
    if (response.status === 503) {
        return true;
    }

    // Gateway errors - retry
    if (response.status === 502 || response.status === 504) {
        return true;
    }

    // Client errors - don't retry
    if (response.status >= 400 && response.status < 500) {
        return false;
    }

    return false;
}

async function fetchWithRetry(url, maxRetries = 5) {
    let delay = 1000;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            // Check if retryable
            if (!isRetryable(null, response)) {
                throw new Error(`Non-retryable error: ${response.status}`);
            }

            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            // Network error - check if retryable
            if (!isRetryable(error, null)) {
                throw error;
            }

            if (attempt === maxRetries - 1) {
                throw error;
            }

            const jitter = Math.random() * delay;
            await sleep(jitter);
            delay *= 2;
        }
    }
}

Respecting Retry-After Header

async function fetchWithRetry(url, maxRetries = 5) {
    let delay = 1000;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
            const response = await fetch(url);

            if (response.ok) {
                return await response.json();
            }

            // Rate limited - respect Retry-After header
            if (response.status === 429) {
                const retryAfter = response.headers.get('Retry-After');

                if (retryAfter) {
                    // Retry-After can be seconds or HTTP date
                    const retryDelay = isNaN(retryAfter)
                        ? new Date(retryAfter).getTime() - Date.now()
                        : parseInt(retryAfter) * 1000;

                    console.log(`Rate limited, waiting ${retryDelay}ms as requested`);
                    await sleep(retryDelay);
                    continue;
                }
            }

            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            if (attempt === maxRetries - 1) {
                throw error;
            }

            const jitter = Math.random() * delay;
            await sleep(jitter);
            delay *= 2;
        }
    }
}

Axios with Retry

const axios = require('axios');
const axiosRetry = require('axios-retry');

// Configure axios to retry
axiosRetry(axios, {
    retries: 5,
    retryDelay: axiosRetry.exponentialDelay,
    retryCondition: (error) => {
        // Retry on network errors or 5xx responses
        return axiosRetry.isNetworkOrIdempotentRequestError(error)
            || error.response?.status >= 500;
    },
    onRetry: (retryCount, error, requestConfig) => {
        console.log(`Retry attempt ${retryCount} for ${requestConfig.url}`);
    }
});

// Use axios normally - retries automatically
try {
    const response = await axios.get('https://api.example.com/data');
    console.log(response.data);
} catch (error) {
    console.error('Failed after retries:', error);
}

Promise-Retry Library

const promiseRetry = require('promise-retry');

const data = await promiseRetry(async (retry, number) => {
    console.log(`Attempt ${number}`);

    try {
        const response = await fetch('https://api.example.com/data');

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        return await response.json();
    } catch (error) {
        // Retry on failure
        retry(error);
    }
}, {
    retries: 5,
    factor: 2,
    minTimeout: 1000,
    maxTimeout: 32000,
    randomize: true // Add jitter
});

Webhook Retry Pattern

class WebhookRetryQueue {
    constructor() {
        this.queue = [];
        this.processing = false;
    }

    async send(url, payload) {
        this.queue.push({ url, payload, attempts: 0 });

        if (!this.processing) {
            this.processQueue();
        }
    }

    async processQueue() {
        this.processing = true;

        while (this.queue.length > 0) {
            const webhook = this.queue[0];

            try {
                await this.sendWithRetry(webhook.url, webhook.payload);
                this.queue.shift(); // Success - remove from queue
            } catch (error) {
                webhook.attempts++;

                if (webhook.attempts >= 5) {
                    console.error('Webhook failed after 5 attempts:', webhook.url);
                    this.queue.shift(); // Give up
                } else {
                    // Move to back of queue
                    this.queue.shift();
                    this.queue.push(webhook);

                    // Wait before processing next
                    await sleep(Math.pow(2, webhook.attempts) * 1000);
                }
            }
        }

        this.processing = false;
    }

    async sendWithRetry(url, payload) {
        const response = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        return response;
    }
}

// Usage
const webhookQueue = new WebhookRetryQueue();

app.post('/order', async (req, res) => {
    const order = await createOrder(req.body);

    // Send webhook - will retry on failure
    webhookQueue.send('https://partner.com/webhook', {
        event: 'order.created',
        order: order
    });

    res.json(order);
});

Circuit Breaker Pattern

Stop retrying if service is down:

class CircuitBreaker {
    constructor(threshold = 5, timeout = 60000) {
        this.failureCount = 0;
        this.threshold = threshold;
        this.timeout = timeout;
        this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
        this.nextAttempt = Date.now();
    }

    async execute(fn) {
        if (this.state === 'OPEN') {
            if (Date.now() < this.nextAttempt) {
                throw new Error('Circuit breaker is OPEN');
            }
            this.state = 'HALF_OPEN';
        }

        try {
            const result = await fn();
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }

    onSuccess() {
        this.failureCount = 0;
        this.state = 'CLOSED';
    }

    onFailure() {
        this.failureCount++;

        if (this.failureCount >= this.threshold) {
            this.state = 'OPEN';
            this.nextAttempt = Date.now() + this.timeout;
            console.log(`Circuit breaker OPEN for ${this.timeout}ms`);
        }
    }
}

// Usage
const breaker = new CircuitBreaker(5, 60000); // Open after 5 failures, retry after 60s

async function callAPI() {
    return await breaker.execute(async () => {
        return await fetchWithRetry('https://api.example.com/data');
    });
}

Real-World Example

class APIClient {
    constructor(baseURL) {
        this.baseURL = baseURL;
    }

    async request(endpoint, options = {}) {
        const {
            method = 'GET',
            body = null,
            maxRetries = 5
        } = options;

        let delay = 1000;

        for (let attempt = 0; attempt < maxRetries; attempt++) {
            try {
                const response = await fetch(`${this.baseURL}${endpoint}`, {
                    method,
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${this.getToken()}`
                    },
                    body: body ? JSON.stringify(body) : null
                });

                // Success
                if (response.ok) {
                    return await response.json();
                }

                // Rate limited
                if (response.status === 429) {
                    const retryAfter = response.headers.get('Retry-After');
                    delay = retryAfter ? parseInt(retryAfter) * 1000 : delay;
                    throw new Error('Rate limited');
                }

                // Client error - don't retry
                if (response.status >= 400 && response.status < 500) {
                    throw new Error(`Client error: ${response.status}`);
                }

                // Server error - retry
                throw new Error(`Server error: ${response.status}`);

            } catch (error) {
                // Don't retry client errors
                if (error.message.includes('Client error')) {
                    throw error;
                }

                const isLastAttempt = attempt === maxRetries - 1;
                if (isLastAttempt) {
                    throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
                }

                const jitter = Math.random() * delay;
                console.log(`Attempt ${attempt + 1} failed, retrying in ${Math.round(jitter)}ms...`);
                await sleep(jitter);
                delay = Math.min(delay * 2, 32000);
            }
        }
    }

    getToken() {
        return 'your-api-token';
    }
}

// Usage
const api = new APIClient('https://api.example.com');

try {
    const data = await api.request('/users', { maxRetries: 3 });
    console.log(data);
} catch (error) {
    console.error('API request failed:', error);
}

Testing Retry Logic

describe('fetchWithRetry', () => {
    it('should succeed on first attempt', async () => {
        const mockFetch = jest.fn().mockResolvedValue({
            ok: true,
            json: async () => ({ data: 'success' })
        });
        global.fetch = mockFetch;

        const result = await fetchWithRetry('https://api.example.com/data');

        expect(result).toEqual({ data: 'success' });
        expect(mockFetch).toHaveBeenCalledTimes(1);
    });

    it('should retry on failure and eventually succeed', async () => {
        const mockFetch = jest.fn()
            .mockRejectedValueOnce(new Error('Network error'))
            .mockRejectedValueOnce(new Error('Network error'))
            .mockResolvedValueOnce({
                ok: true,
                json: async () => ({ data: 'success' })
            });
        global.fetch = mockFetch;

        const result = await fetchWithRetry('https://api.example.com/data', 3);

        expect(result).toEqual({ data: 'success' });
        expect(mockFetch).toHaveBeenCalledTimes(3);
    });

    it('should throw after max retries', async () => {
        const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
        global.fetch = mockFetch;

        await expect(
            fetchWithRetry('https://api.example.com/data', 3)
        ).rejects.toThrow('Network error');

        expect(mockFetch).toHaveBeenCalledTimes(3);
    });
});

Common Mistakes

Mistake 1: Retrying Non-Idempotent Operations

// BAD - Retrying POST can create duplicates
async function createUser(userData) {
    return await fetchWithRetry('/users', {
        method: 'POST',
        body: userData
    });
}
// Request fails after creating user → retry creates duplicate!

// GOOD - Use idempotency key
async function createUser(userData) {
    return await fetch('/users', {
        method: 'POST',
        headers: {
            'Idempotency-Key': generateUniqueKey()
        },
        body: userData
    });
}

Mistake 2: No Maximum Delay

// BAD - Delay grows forever
let delay = 1000;
delay *= 2; // 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s...

// GOOD - Cap maximum delay
const maxDelay = 32000;
delay = Math.min(delay * 2, maxDelay);

Mistake 3: Not Logging Retries

// BAD - Silent retries
await fetchWithRetry(url);

// GOOD - Log retries for debugging
await fetchWithRetry(url, {
    onRetry: (attempt, delay, error) => {
        console.log(`Retry ${attempt} after ${delay}ms: ${error.message}`);
    }
});

The Bottom Line

Network requests fail. Retrying with exponential backoff handles transient failures gracefully.

Start with 1 second, double each retry - 1s, 2s, 4s, 8s prevents hammering.

Add jitter - randomize delays to prevent thundering herd problems.

Respect Retry-After - honor rate limit headers from servers.

Don't retry forever - max 5 retries is usually enough.

We lost payment webhooks because we didn't retry. Added exponential backoff, haven't lost one since. Five lines of code fixed a recurring problem.

Add retry logic to critical requests today. Start simple with exponential backoff. Your users won't notice failures anymore.