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
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
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:
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)
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
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
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
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