JavaScript Essentials

Promises & Promise Chaining

45 min Lesson 40 of 60

The Problem with Callbacks

Before Promises existed in JavaScript, asynchronous operations were handled exclusively through callbacks -- functions passed as arguments to be executed when an operation completes. While callbacks work for simple cases, they quickly become unmanageable when you need to perform multiple asynchronous operations in sequence. Each subsequent operation must be nested inside the callback of the previous one, creating a deeply indented, hard-to-read structure known as callback hell (also called the "pyramid of doom").

Example: Callback Hell

// Simulating API calls with callbacks
function getUser(userId, callback) {
    setTimeout(() => {
        callback(null, { id: userId, name: 'Alice', teamId: 5 });
    }, 1000);
}

function getTeam(teamId, callback) {
    setTimeout(() => {
        callback(null, { id: teamId, name: 'Engineering', companyId: 3 });
    }, 1000);
}

function getCompany(companyId, callback) {
    setTimeout(() => {
        callback(null, { id: companyId, name: 'TechCorp' });
    }, 1000);
}

// The pyramid of doom -- each call nested inside the previous
getUser(1, function(err, user) {
    if (err) {
        console.error('Failed to get user:', err);
        return;
    }
    getTeam(user.teamId, function(err, team) {
        if (err) {
            console.error('Failed to get team:', err);
            return;
        }
        getCompany(team.companyId, function(err, company) {
            if (err) {
                console.error('Failed to get company:', err);
                return;
            }
            console.log(`${user.name} works at ${company.name}`);
            // Imagine adding more nested calls here...
        });
    });
});

The problems with callback hell go beyond aesthetics. Error handling becomes repetitive and error-prone because you must check for errors at every level. Control flow is difficult to reason about. Adding new steps in the middle of the chain requires restructuring the entire nesting. And returning a value from deeply nested callbacks to the outer scope is practically impossible. Promises were created to solve all of these problems.

Note: Callbacks are not inherently bad. They work well for simple event handlers (like click listeners) and single asynchronous operations. The problem arises specifically when you need to coordinate multiple asynchronous operations in sequence or in parallel.

The Promise Concept

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of it as a placeholder for a value that does not exist yet but will exist at some point in the future. A Promise is always in one of three states:

  • Pending -- The initial state. The operation has not completed yet.
  • Fulfilled -- The operation completed successfully, and the Promise has a resulting value.
  • Rejected -- The operation failed, and the Promise has a reason for the failure (an error).

Once a Promise is fulfilled or rejected, it is said to be settled, and its state can never change again. A fulfilled Promise will not become rejected, and vice versa. This immutability is one of the key strengths of Promises -- once you have a result, you can trust it.

Pro Tip: A helpful analogy is a restaurant order. When you place an order, you get a receipt (the Promise). The receipt is not the food itself, but a guarantee that food will come. The order is "pending" while the kitchen works. It becomes "fulfilled" when your food arrives, or "rejected" if the kitchen cannot make the dish. Either way, the receipt gives you a way to wait for and react to the result.

Creating Promises

You create a new Promise using the new Promise() constructor. The constructor takes a single argument: a function called the executor. The executor receives two arguments: resolve and reject. Call resolve(value) when the operation succeeds, and call reject(reason) when it fails.

Example: Creating a Promise

// Basic Promise creation
const myPromise = new Promise((resolve, reject) => {
    // Simulate an asynchronous operation
    const success = true;

    setTimeout(() => {
        if (success) {
            resolve('Operation completed successfully!');
        } else {
            reject(new Error('Operation failed.'));
        }
    }, 1000);
});

console.log(myPromise); // Promise { <pending> }
// After 1 second, the Promise will be fulfilled

Example: Wrapping a Callback-Based API in a Promise

// Convert a callback function to return a Promise
function getUserPromise(userId) {
    return new Promise((resolve, reject) => {
        // Simulating a database call
        setTimeout(() => {
            if (userId <= 0) {
                reject(new Error('Invalid user ID'));
                return;
            }

            resolve({
                id: userId,
                name: 'Alice',
                email: 'alice@example.com'
            });
        }, 1000);
    });
}

// Now we can use this with .then() and .catch()
getUserPromise(1);  // Returns a Promise
getUserPromise(-1); // Returns a Promise that will reject
Important: The executor function runs immediately when you create the Promise. It is not deferred. Only the resolve and reject calls (and the subsequent .then() and .catch() handlers) are asynchronous. Also, calling resolve() or reject() more than once has no effect -- only the first call matters. A Promise can only be settled once.

The .then() Handler

The .then() method is how you access the value of a fulfilled Promise. It takes up to two arguments: a callback for the fulfilled case and an optional callback for the rejected case. The .then() method itself returns a new Promise, which is the foundation of promise chaining.

Example: Using .then()

const promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve(42), 1000);
});

// Basic .then() usage
promise.then((value) => {
    console.log('The answer is:', value); // "The answer is: 42"
});

// .then() with both handlers
promise.then(
    (value) => {
        console.log('Success:', value);
    },
    (error) => {
        console.log('Error:', error.message);
    }
);

// .then() always returns a new Promise
const newPromise = promise.then((value) => {
    return value * 2;
});

newPromise.then((doubled) => {
    console.log('Doubled:', doubled); // "Doubled: 84"
});

The return value of the .then() callback determines the value of the returned Promise. If you return a regular value, the next .then() receives that value. If you return a Promise, the chain waits for that Promise to settle before continuing. If you throw an error, the returned Promise is rejected.

Example: Return Values in .then()

Promise.resolve(10)
    .then((value) => {
        console.log(value);   // 10
        return value + 5;     // Return a value
    })
    .then((value) => {
        console.log(value);   // 15
        return new Promise((resolve) => {
            setTimeout(() => resolve(value * 2), 500);
        });                    // Return a Promise
    })
    .then((value) => {
        console.log(value);   // 30 (waited for the inner Promise)
        // No return -- implicitly returns undefined
    })
    .then((value) => {
        console.log(value);   // undefined
    });

The .catch() Handler

The .catch() method handles rejected Promises. It is equivalent to calling .then(undefined, onRejected), but it is more readable and has one important advantage: it catches errors thrown anywhere in the preceding chain, not just in the immediately preceding .then().

Example: Using .catch()

// Catching a rejected Promise
const failedPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject(new Error('Network timeout'));
    }, 1000);
});

failedPromise.catch((error) => {
    console.error('Caught:', error.message);
    // "Caught: Network timeout"
});

// .catch() catches errors from anywhere in the chain
Promise.resolve('start')
    .then((value) => {
        console.log(value);  // "start"
        throw new Error('Something went wrong');
    })
    .then((value) => {
        // This is SKIPPED because the previous .then() threw
        console.log('This never runs');
    })
    .catch((error) => {
        console.error(error.message); // "Something went wrong"
        return 'recovered'; // You can recover from errors!
    })
    .then((value) => {
        console.log(value); // "recovered" -- chain continues
    });
Pro Tip: Always use .catch() at the end of your promise chains instead of passing a second argument to .then(). The .catch() approach catches errors from all preceding .then() handlers, while a rejection handler in .then() only catches rejections from the Promise it is directly attached to, not from the fulfillment handler in the same .then() call.

The .finally() Handler

The .finally() method runs a callback when the Promise is settled, regardless of whether it was fulfilled or rejected. It is useful for cleanup operations like hiding a loading spinner, closing a database connection, or releasing resources. The callback receives no arguments and the return value is ignored (unless you throw an error or return a rejected Promise).

Example: Using .finally()

function fetchData(url) {
    console.log('Loading...');
    showLoadingSpinner();

    return fetch(url)
        .then((response) => {
            if (!response.ok) {
                throw new Error(`HTTP error! Status: ${response.status}`);
            }
            return response.json();
        })
        .then((data) => {
            console.log('Data received:', data);
            return data;
        })
        .catch((error) => {
            console.error('Fetch failed:', error.message);
            return null; // Return fallback value
        })
        .finally(() => {
            // Runs whether the fetch succeeded or failed
            hideLoadingSpinner();
            console.log('Loading complete.');
        });
}

// .finally() does not change the resolved value
Promise.resolve(42)
    .finally(() => {
        console.log('Cleanup'); // runs
        return 999;             // ignored!
    })
    .then((value) => {
        console.log(value);     // 42 -- not 999
    });

Promise Chaining

Promise chaining is the technique of connecting multiple .then() calls in sequence. Each .then() returns a new Promise, allowing the next .then() to wait for the previous operation to complete. This is the primary advantage of Promises over callbacks -- sequential asynchronous operations become a flat, readable chain instead of nested callbacks.

Example: Basic Promise Chain

// Converting the callback hell example to a promise chain
function getUser(userId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: userId, name: 'Alice', teamId: 5 });
        }, 1000);
    });
}

function getTeam(teamId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: teamId, name: 'Engineering', companyId: 3 });
        }, 1000);
    });
}

function getCompany(companyId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: companyId, name: 'TechCorp' });
        }, 1000);
    });
}

// Clean, flat chain instead of nested callbacks!
getUser(1)
    .then((user) => {
        console.log('Got user:', user.name);
        return getTeam(user.teamId);
    })
    .then((team) => {
        console.log('Got team:', team.name);
        return getCompany(team.companyId);
    })
    .then((company) => {
        console.log('Got company:', company.name);
    })
    .catch((error) => {
        console.error('Something failed:', error.message);
    });

Returning Values in Chains

Each .then() in a chain can transform the value before passing it to the next step. If you return a plain value, it becomes the fulfilled value of the next Promise. If you return a Promise, the chain waits for it to resolve. If you do not return anything, undefined is passed to the next step.

Example: Data Transformation Chain

// Process data through a series of transformations
function fetchRawData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('  42, 17, 93, 5, 28, 61  ');
        }, 500);
    });
}

fetchRawData()
    .then((raw) => {
        // Step 1: Trim whitespace
        return raw.trim();
    })
    .then((trimmed) => {
        // Step 2: Split into array
        return trimmed.split(', ');
    })
    .then((strings) => {
        // Step 3: Convert to numbers
        return strings.map(Number);
    })
    .then((numbers) => {
        // Step 4: Sort ascending
        return numbers.sort((a, b) => a - b);
    })
    .then((sorted) => {
        // Step 5: Calculate statistics
        const sum = sorted.reduce((acc, n) => acc + n, 0);
        return {
            sorted: sorted,
            min: sorted[0],
            max: sorted[sorted.length - 1],
            average: sum / sorted.length
        };
    })
    .then((stats) => {
        console.log('Statistics:', stats);
        // { sorted: [5, 17, 28, 42, 61, 93],
        //   min: 5, max: 93, average: 41 }
    });
Important: A very common mistake is forgetting to return a Promise inside a .then() handler. If you call an asynchronous function without returning it, the chain does not wait for it. The next .then() runs immediately with undefined. Always return Promises in chain steps.

Example: The Missing Return Bug

// BUG: Missing return
getUser(1)
    .then((user) => {
        getTeam(user.teamId); // <-- Missing return!
    })
    .then((team) => {
        console.log(team); // undefined -- did not wait for getTeam
    });

// CORRECT: With return
getUser(1)
    .then((user) => {
        return getTeam(user.teamId); // <-- Returned!
    })
    .then((team) => {
        console.log(team); // { id: 5, name: "Engineering", ... }
    });

Promise.resolve() and Promise.reject()

These are static methods that create immediately settled Promises. Promise.resolve(value) creates a fulfilled Promise with the given value. Promise.reject(reason) creates a rejected Promise with the given reason. They are useful for starting chains, creating test data, and normalizing values that might or might not be Promises.

Example: Promise.resolve() and Promise.reject()

// Promise.resolve creates an immediately fulfilled Promise
const fulfilled = Promise.resolve(42);
fulfilled.then((value) => console.log(value)); // 42

// Promise.reject creates an immediately rejected Promise
const rejected = Promise.reject(new Error('Failed'));
rejected.catch((err) => console.error(err.message)); // "Failed"

// Useful for starting a chain
Promise.resolve()
    .then(() => fetchData())
    .then((data) => processData(data))
    .catch((error) => handleError(error));

// Normalizing values: always returns a Promise
function ensurePromise(valueOrPromise) {
    if (valueOrPromise instanceof Promise) {
        return valueOrPromise;
    }
    return Promise.resolve(valueOrPromise);
}

// Works with both sync and async values
ensurePromise(42).then(console.log);              // 42
ensurePromise(fetchData()).then(console.log);      // [async data]

// If you pass a Promise to Promise.resolve, it returns the same Promise
const original = new Promise((resolve) => resolve('hello'));
const wrapped = Promise.resolve(original);
console.log(original === wrapped); // true

Promise.all() -- Parallel Execution

Promise.all() takes an iterable (usually an array) of Promises and returns a single Promise that fulfills when all input Promises have fulfilled. The result is an array of all the fulfilled values, in the same order as the input. If any one of the input Promises rejects, Promise.all() immediately rejects with that error, discarding the results of any fulfilled Promises.

Example: Promise.all() for Parallel Requests

// Simulate API calls with different durations
function fetchUserProfile(id) {
    return new Promise((resolve) => {
        setTimeout(() => resolve({ id, name: 'Alice' }), 1000);
    });
}

function fetchUserPosts(id) {
    return new Promise((resolve) => {
        setTimeout(() => resolve([
            { title: 'First Post' },
            { title: 'Second Post' }
        ]), 1500);
    });
}

function fetchUserFriends(id) {
    return new Promise((resolve) => {
        setTimeout(() => resolve(['Bob', 'Charlie']), 800);
    });
}

// All three requests run in PARALLEL
// Total time: ~1500ms (the slowest request), not 3300ms
Promise.all([
    fetchUserProfile(1),
    fetchUserPosts(1),
    fetchUserFriends(1)
])
.then(([profile, posts, friends]) => {
    // Destructure the results array
    console.log('Profile:', profile);
    console.log('Posts:', posts);
    console.log('Friends:', friends);
})
.catch((error) => {
    console.error('One of the requests failed:', error.message);
});

Example: Promise.all() Fails Fast

// If ANY Promise rejects, the whole thing rejects
const promises = [
    Promise.resolve('A'),
    Promise.reject(new Error('B failed')),
    Promise.resolve('C')  // This still runs, but its result is discarded
];

Promise.all(promises)
    .then((results) => {
        console.log('This never runs');
    })
    .catch((error) => {
        console.error(error.message); // "B failed"
    });

// Common pattern: fetch multiple resources or fail entirely
async function loadDashboard() {
    try {
        const [users, orders, analytics] = await Promise.all([
            fetch('/api/users').then(r => r.json()),
            fetch('/api/orders').then(r => r.json()),
            fetch('/api/analytics').then(r => r.json())
        ]);
        renderDashboard(users, orders, analytics);
    } catch (error) {
        showError('Failed to load dashboard data');
    }
}
Note: When Promise.all() rejects because one Promise fails, the other Promises are not cancelled. They continue executing in the background; their results are simply ignored. JavaScript does not have a built-in mechanism for cancelling Promises (though the AbortController API can cancel fetch requests).

Promise.allSettled()

Promise.allSettled() waits for all input Promises to settle (either fulfill or reject) and returns an array of objects describing the outcome of each Promise. Unlike Promise.all(), it never short-circuits on rejection. This makes it ideal when you want to attempt multiple operations and handle each result independently, regardless of whether some failed.

Example: Promise.allSettled()

const promises = [
    fetch('https://api.example.com/users'),
    fetch('https://api.broken.com/data'),   // This will fail
    fetch('https://api.example.com/products')
];

Promise.allSettled(promises)
    .then((results) => {
        results.forEach((result, index) => {
            if (result.status === 'fulfilled') {
                console.log(`Request ${index} succeeded:`, result.value);
            } else {
                console.error(`Request ${index} failed:`, result.reason);
            }
        });
    });

// result objects look like:
// { status: "fulfilled", value: Response }
// { status: "rejected", reason: TypeError }
// { status: "fulfilled", value: Response }

// Practical use: batch operations with partial failure handling
function saveMultipleRecords(records) {
    const savePromises = records.map(record =>
        fetch('/api/save', {
            method: 'POST',
            body: JSON.stringify(record)
        })
    );

    return Promise.allSettled(savePromises).then((results) => {
        const succeeded = results.filter(r => r.status === 'fulfilled');
        const failed = results.filter(r => r.status === 'rejected');

        console.log(`${succeeded.length} saved, ${failed.length} failed`);
        return { succeeded, failed };
    });
}

Promise.race()

Promise.race() returns a Promise that settles as soon as the first input Promise settles, whether it fulfills or rejects. The remaining Promises continue running but their results are ignored. This is useful for implementing timeouts, racing multiple data sources, or taking the first available result.

Example: Promise.race() for Timeouts

// Create a timeout Promise
function timeout(ms) {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error(`Operation timed out after ${ms}ms`));
        }, ms);
    });
}

// Race a fetch against a timeout
function fetchWithTimeout(url, ms = 5000) {
    return Promise.race([
        fetch(url),
        timeout(ms)
    ]);
}

// Usage: if fetch takes longer than 3 seconds, reject
fetchWithTimeout('https://api.example.com/data', 3000)
    .then((response) => {
        console.log('Got response in time!');
        return response.json();
    })
    .catch((error) => {
        console.error(error.message);
        // Either "Operation timed out after 3000ms"
        // or a network error from fetch
    });

// Race multiple sources for the fastest response
function fetchFromFastest(urls) {
    return Promise.race(
        urls.map(url => fetch(url).then(r => r.json()))
    );
}

fetchFromFastest([
    'https://cdn1.example.com/data.json',
    'https://cdn2.example.com/data.json',
    'https://cdn3.example.com/data.json'
]).then((data) => {
    console.log('Got data from the fastest CDN:', data);
});

Promise.any()

Promise.any() returns a Promise that fulfills as soon as the first input Promise fulfills. It ignores rejections unless all Promises reject, in which case it rejects with an AggregateError containing all the rejection reasons. This is the opposite of Promise.all() in terms of its fulfillment logic: Promise.all() needs all to succeed, while Promise.any() needs just one to succeed.

Example: Promise.any()

// Try multiple sources, take the first success
const mirrors = [
    fetch('https://mirror1.example.com/package.tar.gz'),
    fetch('https://mirror2.example.com/package.tar.gz'),
    fetch('https://mirror3.example.com/package.tar.gz')
];

Promise.any(mirrors)
    .then((response) => {
        console.log('Downloaded from:', response.url);
    })
    .catch((aggregateError) => {
        // Only reaches here if ALL mirrors failed
        console.error('All mirrors failed:');
        aggregateError.errors.forEach((err, i) => {
            console.error(`  Mirror ${i + 1}: ${err.message}`);
        });
    });

// Difference from Promise.race():
// race() settles on the FIRST settled (success OR failure)
// any() settles on the FIRST fulfilled (success only)

// If the fastest mirror returns an error, race() would reject
// but any() would wait for a successful response

const example = [
    Promise.reject('Error 1'),   // fastest, but rejected
    new Promise((resolve) =>
        setTimeout(() => resolve('Success!'), 1000)
    )
];

Promise.race(example).catch(console.log);  // "Error 1" (first settled)
Promise.any(example).then(console.log);    // "Success!" (first fulfilled)

Error Propagation in Chains

One of the most important aspects of Promises is how errors propagate through chains. When a Promise rejects or a .then() handler throws an error, the error propagates down the chain, skipping all .then() handlers until it finds a .catch() handler. This is similar to how exceptions propagate up through try/catch blocks in synchronous code.

Example: Error Propagation

Promise.resolve('start')
    .then((value) => {
        console.log('Step 1:', value); // "Step 1: start"
        return 'step 1 done';
    })
    .then((value) => {
        console.log('Step 2:', value); // "Step 2: step 1 done"
        throw new Error('Step 2 failed!');
    })
    .then((value) => {
        // SKIPPED -- error is propagating
        console.log('Step 3:', value);
    })
    .then((value) => {
        // SKIPPED -- error is still propagating
        console.log('Step 4:', value);
    })
    .catch((error) => {
        // Catches the error from Step 2
        console.error('Caught:', error.message);
        // "Caught: Step 2 failed!"
        return 'recovered';
    })
    .then((value) => {
        // Chain CONTINUES after catch
        console.log('Step 5:', value); // "Step 5: recovered"
    });

Example: Multiple .catch() Handlers

// You can have multiple catch handlers for different sections
fetchUserData()
    .then(validateData)
    .catch((error) => {
        // Handles errors from fetchUserData or validateData
        console.error('Data validation failed:', error.message);
        return getDefaultData(); // Try fallback
    })
    .then(transformData)
    .then(saveToDatabase)
    .catch((error) => {
        // Handles errors from transformData or saveToDatabase
        // Also catches if getDefaultData() failed
        console.error('Processing failed:', error.message);
    });

// Re-throwing errors
Promise.resolve()
    .then(() => {
        throw new Error('original error');
    })
    .catch((error) => {
        console.log('First catch:', error.message);

        if (error.message.includes('original')) {
            throw error; // Re-throw to propagate further
        }
        return 'handled';
    })
    .catch((error) => {
        console.log('Second catch:', error.message);
        // "Second catch: original error"
    });
Important: An unhandled Promise rejection (a rejected Promise with no .catch()) will cause an unhandledrejection event in the browser or a warning (and eventually a crash) in Node.js. Always terminate your promise chains with a .catch() handler, or use a global handler as a safety net: window.addEventListener('unhandledrejection', (event) => { ... }).

Real-World Examples

Let us apply everything we have learned to solve practical problems that you will encounter in real-world JavaScript development.

Sequential API Calls

When each request depends on data from the previous one, you must chain them sequentially. This is the pattern you saw earlier, but here is a more complete real-world version with proper error handling and data accumulation across steps.

Example: Sequential API Calls with Data Accumulation

function buildUserDashboard(userId) {
    let dashboardData = {};

    return fetchUser(userId)
        .then((user) => {
            dashboardData.user = user;
            return fetchPermissions(user.roleId);
        })
        .then((permissions) => {
            dashboardData.permissions = permissions;

            // Only fetch admin data if the user has admin permissions
            if (permissions.includes('admin')) {
                return fetchAdminStats();
            }
            return null;
        })
        .then((adminStats) => {
            if (adminStats) {
                dashboardData.adminStats = adminStats;
            }
            return fetchNotifications(userId);
        })
        .then((notifications) => {
            dashboardData.notifications = notifications;
            return dashboardData;
        })
        .catch((error) => {
            console.error('Dashboard build failed:', error.message);
            return {
                error: error.message,
                partial: dashboardData
            };
        });
}

// Usage
buildUserDashboard(123).then((dashboard) => {
    if (dashboard.error) {
        renderErrorState(dashboard.error, dashboard.partial);
    } else {
        renderDashboard(dashboard);
    }
});

Parallel Data Loading

When requests are independent of each other, loading them in parallel with Promise.all() dramatically reduces total loading time. Here is a complete pattern for loading a complex page with both required and optional data.

Example: Parallel Loading with Required and Optional Data

function loadPage(pageId) {
    // Required data -- page cannot render without these
    const requiredData = Promise.all([
        fetch(`/api/pages/${pageId}`).then(r => r.json()),
        fetch(`/api/pages/${pageId}/content`).then(r => r.json())
    ]);

    // Optional data -- nice to have, but page works without them
    const optionalData = Promise.allSettled([
        fetch(`/api/pages/${pageId}/comments`).then(r => r.json()),
        fetch(`/api/pages/${pageId}/related`).then(r => r.json()),
        fetch('https://api.analytics.com/track').then(r => r.json())
    ]);

    // Wait for both groups to complete
    return Promise.all([requiredData, optionalData])
        .then(([[page, content], optionalResults]) => {
            const result = { page, content };

            // Extract optional data, using fallbacks for failures
            const [comments, related, analytics] = optionalResults;
            result.comments = comments.status === 'fulfilled'
                ? comments.value : [];
            result.related = related.status === 'fulfilled'
                ? related.value : [];

            return result;
        });
}

// Usage
loadPage('about-us')
    .then(({ page, content, comments, related }) => {
        renderPage(page);
        renderContent(content);
        renderComments(comments); // May be empty array on failure
        renderRelated(related);   // May be empty array on failure
    })
    .catch((error) => {
        // Only triggers if required data fails
        showErrorPage(error.message);
    });

Example: Retry Pattern with Promises

function fetchWithRetry(url, maxRetries = 3, delay = 1000) {
    return new Promise((resolve, reject) => {
        function attempt(retriesLeft) {
            fetch(url)
                .then((response) => {
                    if (!response.ok) {
                        throw new Error(`HTTP ${response.status}`);
                    }
                    return response.json();
                })
                .then(resolve)
                .catch((error) => {
                    if (retriesLeft <= 0) {
                        reject(new Error(
                            `Failed after ${maxRetries} retries: ${error.message}`
                        ));
                        return;
                    }

                    console.log(
                        `Retry in ${delay}ms. Attempts left: ${retriesLeft}`
                    );

                    setTimeout(() => {
                        attempt(retriesLeft - 1);
                    }, delay);
                });
        }

        attempt(maxRetries);
    });
}

// Usage
fetchWithRetry('/api/flaky-endpoint', 3, 2000)
    .then((data) => console.log('Got data:', data))
    .catch((error) => console.error('Gave up:', error.message));

Example: Sequential Batch Processing

// Process items one at a time to avoid overwhelming the server
function processSequentially(items, processFn) {
    return items.reduce((chain, item) => {
        return chain.then((results) => {
            return processFn(item).then((result) => {
                results.push(result);
                return results;
            });
        });
    }, Promise.resolve([]));
}

// Usage: upload files one by one
const files = ['report.pdf', 'photo.jpg', 'data.csv'];

function uploadFile(filename) {
    return new Promise((resolve) => {
        console.log(`Uploading ${filename}...`);
        setTimeout(() => {
            console.log(`${filename} uploaded!`);
            resolve({ filename, status: 'uploaded' });
        }, 1000);
    });
}

processSequentially(files, uploadFile)
    .then((results) => {
        console.log('All uploads complete:', results);
        // [
        //   { filename: "report.pdf", status: "uploaded" },
        //   { filename: "photo.jpg", status: "uploaded" },
        //   { filename: "data.csv", status: "uploaded" }
        // ]
    });

Promise Anti-Patterns to Avoid

Knowing what not to do is just as important as knowing the correct patterns. Here are the most common mistakes developers make when working with Promises.

Example: Common Anti-Patterns

// ANTI-PATTERN 1: The "Promise Constructor Anti-Pattern"
// Wrapping an existing Promise in a new Promise is unnecessary
// BAD
function fetchDataBad(url) {
    return new Promise((resolve, reject) => {
        fetch(url).then(resolve).catch(reject);
    });
}
// GOOD -- just return the Promise directly
function fetchDataGood(url) {
    return fetch(url);
}

// ANTI-PATTERN 2: Nested Promises (Promise Hell)
// BAD -- this is just callback hell with Promises
function getUserDataBad(id) {
    return getUser(id).then((user) => {
        return getPosts(user.id).then((posts) => {
            return getComments(posts[0].id).then((comments) => {
                return { user, posts, comments };
            });
        });
    });
}
// GOOD -- flat chain with accumulated data
function getUserDataGood(id) {
    let userData = {};
    return getUser(id)
        .then((user) => {
            userData.user = user;
            return getPosts(user.id);
        })
        .then((posts) => {
            userData.posts = posts;
            return getComments(posts[0].id);
        })
        .then((comments) => {
            userData.comments = comments;
            return userData;
        });
}

// ANTI-PATTERN 3: Forgotten return
// BAD -- no return means the chain does not wait
somePromise.then((value) => {
    anotherAsyncOperation(value); // <-- No return!
}).then((result) => {
    // result is undefined
});

// ANTI-PATTERN 4: Using .then() for synchronous transforms
// Not wrong, but overly verbose for sync operations
Promise.resolve(5)
    .then(x => x * 2)
    .then(x => x + 1)
    .then(x => String(x));
// Consider async/await for readability in these cases
Pro Tip: Promises are the foundation for async/await, which you will learn in the next lesson. Every async function returns a Promise, and await is syntactic sugar for .then(). Understanding Promises deeply makes async/await intuitive and helps you debug issues that arise when mixing the two syntaxes.

Practice Exercise

Build a complete data processing pipeline using Promises. Create the following functions that simulate API calls using setTimeout inside new Promise(): (1) fetchUsers() that returns an array of 5 user objects after 500ms. (2) fetchUserDetails(userId) that returns detailed user info after 300ms, and rejects if the userId is 3 (to simulate a failure). (3) fetchUserActivity(userId) that returns an activity log after 400ms. Then implement these patterns: (A) Use Promise.all() to fetch details for all 5 users in parallel, and handle the case where userId 3 fails. (B) Use Promise.allSettled() to fetch details for all users and display which succeeded and which failed. (C) Create a fetchWithTimeout(promise, ms) function using Promise.race() that rejects if the given promise takes longer than the specified time. (D) Chain fetchUsers() then fetchUserDetails() then fetchUserActivity() sequentially, accumulating data at each step, with a .catch() that provides a partial result. (E) Implement a retryPromise(fn, maxRetries) function that retries a failed promise-returning function up to the specified number of times. Test each pattern and log the results to the console.