JavaScript Promises
Promise Retry
Error Handling
Async Programming
Undo Functionality

How to implement a Promise retry and undo

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Promise retry wraps an async operation so it automatically retries on failure up to a maximum number of attempts, optionally with exponential backoff between retries. Promise undo maintains a stack of compensating actions. When a step in a multi-step async workflow fails, it executes the undo functions for all previously completed steps in reverse order. Together, these patterns build resilient async workflows that can recover from transient failures and roll back partial changes.

Basic Promise Retry

javascript
1function retry(fn, maxRetries = 3) {
2    return fn().catch(err => {
3        if (maxRetries <= 0) throw err;
4        return retry(fn, maxRetries - 1);
5    });
6}
7
8// Usage
9retry(() => fetch('https://api.example.com/data'), 3)
10    .then(response => response.json())
11    .then(data => console.log(data))
12    .catch(err => console.error('All retries failed:', err));

Each failure reduces maxRetries by 1. When it reaches 0, the error propagates.

Retry with Delay and Exponential Backoff

javascript
1function delay(ms) {
2    return new Promise(resolve => setTimeout(resolve, ms));
3}
4
5async function retryWithBackoff(fn, options = {}) {
6    const { maxRetries = 3, baseDelay = 1000, factor = 2 } = options;
7
8    for (let attempt = 0; attempt <= maxRetries; attempt++) {
9        try {
10            return await fn();
11        } catch (err) {
12            if (attempt === maxRetries) throw err;
13
14            const waitTime = baseDelay * Math.pow(factor, attempt);
15            const jitter = waitTime * Math.random() * 0.1;
16            console.log(`Attempt ${attempt + 1} failed, retrying in ${waitTime}ms`);
17            await delay(waitTime + jitter);
18        }
19    }
20}
21
22// Usage
23const data = await retryWithBackoff(
24    () => fetch('https://api.example.com/data').then(r => {
25        if (!r.ok) throw new Error(`HTTP ${r.status}`);
26        return r.json();
27    }),
28    { maxRetries: 5, baseDelay: 500, factor: 2 }
29);
30// Delays: 500ms, 1000ms, 2000ms, 4000ms, 8000ms

Exponential backoff prevents thundering herd problems where many clients retry simultaneously.

Retry with Condition

Only retry on specific errors:

javascript
1async function retryIf(fn, shouldRetry, maxRetries = 3) {
2    for (let attempt = 0; attempt <= maxRetries; attempt++) {
3        try {
4            return await fn();
5        } catch (err) {
6            if (attempt === maxRetries || !shouldRetry(err)) throw err;
7            await delay(1000 * Math.pow(2, attempt));
8        }
9    }
10}
11
12// Only retry on network errors or 5xx status codes
13await retryIf(
14    () => fetch('/api/data').then(r => {
15        if (r.status >= 500) throw new Error(`Server error: ${r.status}`);
16        if (!r.ok) throw new Error(`Client error: ${r.status}`);
17        return r.json();
18    }),
19    (err) => err.message.includes('Server error') || err.name === 'TypeError',
20    3
21);
22// Retries on 500s and network failures, NOT on 400/401/404

Basic Promise Undo (Saga Pattern)

javascript
1async function runWithUndo(steps) {
2    const completed = [];
3
4    for (const step of steps) {
5        try {
6            const result = await step.execute();
7            completed.push({ undo: step.undo, result });
8        } catch (err) {
9            // Roll back all completed steps in reverse order
10            console.error(`Step failed: ${err.message}. Rolling back...`);
11
12            for (const entry of completed.reverse()) {
13                try {
14                    await entry.undo(entry.result);
15                } catch (undoErr) {
16                    console.error(`Undo failed: ${undoErr.message}`);
17                }
18            }
19
20            throw err;
21        }
22    }
23
24    return completed.map(c => c.result);
25}

Undo Example: Multi-Step Order Processing

javascript
1const orderSteps = [
2    {
3        execute: async () => {
4            const payment = await chargeCard(order.total);
5            return payment.transactionId;
6        },
7        undo: async (transactionId) => {
8            await refundCharge(transactionId);
9            console.log('Payment refunded');
10        }
11    },
12    {
13        execute: async () => {
14            const reservation = await reserveInventory(order.items);
15            return reservation.id;
16        },
17        undo: async (reservationId) => {
18            await releaseInventory(reservationId);
19            console.log('Inventory released');
20        }
21    },
22    {
23        execute: async () => {
24            const shipment = await createShipment(order);
25            return shipment.trackingNumber;
26        },
27        undo: async (trackingNumber) => {
28            await cancelShipment(trackingNumber);
29            console.log('Shipment cancelled');
30        }
31    }
32];
33
34try {
35    const [transactionId, reservationId, trackingNumber] =
36        await runWithUndo(orderSteps);
37    console.log('Order complete:', { transactionId, reservationId, trackingNumber });
38} catch (err) {
39    console.error('Order failed and rolled back:', err.message);
40    // If shipment creation fails:
41    // → inventory released
42    // → payment refunded
43}

Combining Retry with Undo

javascript
1async function runWithRetryAndUndo(steps, retryOptions = {}) {
2    const completed = [];
3
4    for (const step of steps) {
5        try {
6            const result = await retryWithBackoff(
7                step.execute,
8                retryOptions
9            );
10            completed.push({ undo: step.undo, result });
11        } catch (err) {
12            // All retries exhausted, roll back
13            for (const entry of [...completed].reverse()) {
14                try {
15                    await entry.undo(entry.result);
16                } catch (undoErr) {
17                    console.error(`Undo failed: ${undoErr.message}`);
18                }
19            }
20            throw err;
21        }
22    }
23
24    return completed.map(c => c.result);
25}
26
27// Each step retries 3 times before triggering rollback
28await runWithRetryAndUndo(orderSteps, {
29    maxRetries: 3,
30    baseDelay: 500
31});

Class-Based Implementation

javascript
1class AsyncWorkflow {
2    constructor() {
3        this.steps = [];
4    }
5
6    addStep(execute, undo) {
7        this.steps.push({ execute, undo });
8        return this;
9    }
10
11    async run(retryOptions = {}) {
12        const completed = [];
13
14        for (const step of this.steps) {
15            try {
16                const result = retryOptions.maxRetries
17                    ? await retryWithBackoff(step.execute, retryOptions)
18                    : await step.execute();
19                completed.push({ undo: step.undo, result });
20            } catch (err) {
21                await this.rollback(completed);
22                throw err;
23            }
24        }
25        return completed.map(c => c.result);
26    }
27
28    async rollback(completed) {
29        for (const entry of [...completed].reverse()) {
30            try {
31                await entry.undo(entry.result);
32            } catch (err) {
33                console.error('Rollback error:', err);
34            }
35        }
36    }
37}
38
39// Usage
40const workflow = new AsyncWorkflow()
41    .addStep(
42        () => createUser(data),
43        (user) => deleteUser(user.id)
44    )
45    .addStep(
46        () => sendWelcomeEmail(data.email),
47        () => {} // Email cannot be unsent, no-op undo
48    )
49    .addStep(
50        () => setupBilling(data),
51        (billing) => cancelBilling(billing.id)
52    );
53
54await workflow.run({ maxRetries: 2, baseDelay: 1000 });

Common Pitfalls

  • Retrying non-idempotent operations: Retrying a payment charge without checking if the first attempt succeeded can result in double charges. Ensure operations are idempotent (same result when called multiple times) or check for existing results before retrying.
  • Undo that cannot fail: If an undo step fails, you have an inconsistent state. Always wrap undo calls in try/catch, log failures, and consider a dead-letter queue for manual remediation.
  • Missing jitter in backoff: Without jitter (random delay), all clients retry at the same time after a server outage, causing another outage. Add Math.random() * baseDelay * 0.1 to the delay.
  • Retrying on permanent errors: Do not retry 401 Unauthorized or 404 Not Found. They will never succeed. Only retry on transient errors (network timeouts, 500/503).
  • Mutating the completed array during rollback: completed.reverse() mutates the array in place. Use [...completed].reverse() to avoid bugs if the array is referenced elsewhere.

Summary

  • Wrap async functions in a retry loop with configurable max attempts and exponential backoff
  • Add jitter to backoff delays to prevent thundering herd
  • Only retry transient/retryable errors, not permanent ones like 401/404
  • Implement undo by pairing each step with a compensating action and running undos in reverse on failure
  • Combine retry and undo: retry each step before giving up and rolling back
  • Always handle undo failures gracefully. Log them for manual intervention

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.