Skip to main content

🀝 Promises

A Promise is a receipt for a value that doesn't exist yet. Order a coffee and you get a ticket, not the drink β€” but that ticket lets you get on with your day and reliably collect the result later, whether it's a coffee or an apology. Promises give asynchronous JavaScript that same clean, chainable, error-aware structure, and finally flatten the callback pyramid.

Week 2 · Day 4 (Thursday: Asynchronous JavaScript) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe a Promise's three states and how it settles exactly once
  • Create a Promise with the new Promise((resolve, reject) => …) executor
  • Consume Promises with .then, .catch, and .finally
  • Chain dependent async steps and flatten former callback hell
  • Convert a callback-based API into a Promise-based one
  • Choose between Promise.all, race, allSettled, and any

Estimated Time: 70 minutes

Practice: Build a Promise-based delay() and a chainable calculator.

In This Lesson

Why Promises?

In the previous lesson you saw callbacks buckle under nesting β€” the "pyramid of doom" where each async step burrowed one level deeper and the error check was copy-pasted at every layer. A Promise fixes the structure by turning "here's a function to call me back with the result" into "here's an object that represents the result, which you can pass around, chain, and hand error handling to in one place."

Think of the coffee-shop ticket again. The ticket (the Promise) is handed to you immediately. You don't know yet whether it will become a latte or a "sorry, we're out of milk" β€” but you can already plan what to do in either case, and the shop will honor exactly one of those outcomes. That's the whole idea.

Promises are also the foundation for async/await (next lesson) and the return type of the modern fetch API, so this lesson is load-bearing for everything that follows.

The Three States

A Promise is always in exactly one of three states, and once it leaves pending it can never change again β€” it is settled for good.

  • Pending β€” the operation is still in progress; no result yet.
  • Fulfilled β€” it succeeded, carrying a value.
  • Rejected β€” it failed, carrying a reason (usually an Error).
A pending promise settles once, either fulfilled with a value or rejected with a reason pending still working… fulfilled βœ” has a value β†’ .then rejected βœ– has a reason β†’ .catch resolve(value) reject(reason)
One-way, one-time: a pending Promise settles into fulfilled or rejected and stays there forever.

Here is the same lifecycle as a state diagram β€” note there is no path out of the settled states:

stateDiagram-v2 [*] --> Pending Pending --> Fulfilled: resolve(value) Pending --> Rejected: reject(reason) Fulfilled --> [*] Rejected --> [*]

πŸ’‘ "Settled" and "resolved"

Settled means the Promise has finished β€” either fulfilled or rejected. Resolved is subtly different: a Promise is resolved once its fate is locked in, which usually means fulfilled but can also mean "locked onto another Promise's outcome." For everyday use, read resolve as "succeed."

Creating a Promise

You build a Promise by passing an executor function to new Promise. The executor receives two functions from the engine β€” resolve and reject β€” and you call one of them when your async work finishes.

const myPromise = new Promise((resolve, reject) => {
    // The executor runs immediately and starts the async work
    setTimeout(() => {
        const success = true;                 // pretend this came from a server
        if (success) {
            resolve('Operation successful!');  // -> fulfilled with this value
        } else {
            reject(new Error('Operation failed!')); // -> rejected with this reason
        }
    }, 1000);
});

// Consume it: .then for success, .catch for failure
myPromise
    .then(result => console.log(result))        // "Operation successful!"
    .catch(error => console.error(error.message));

πŸ“– Anatomy at a glance

  • new Promise(executor) β€” the constructor; the executor runs synchronously, right now.
  • resolve(value) β€” call to fulfill; value flows into .then.
  • reject(reason) β€” call to reject; reason flows into .catch. Prefer an Error.
  • After the first resolve/reject, further calls are ignored β€” a Promise settles once.

Most of the time you won't write new Promise yourself β€” APIs like fetch hand you one already. You reach for the constructor mainly to wrap something that isn't Promise-based yet (like a timer or an old callback API), which we do below.

then, catch & finally

Three methods let you react to a Promise. Each returns a new Promise, which is exactly why they chain.

MethodRuns whenReceives
.then(onFulfilled)the Promise fulfillsthe resolved value
.catch(onRejected)the Promise (or anything before it) rejectsthe rejection reason
.finally(onSettled)it settles either waynothing β€” for cleanup
const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const n = Math.random();
        n > 0.5 ? resolve(n) : reject(new Error('Number too small'));
    }, 1000);
});

promise
    .then(value => {
        console.log('Success:', value.toFixed(2));
    })
    .catch(error => {
        console.error('Error:', error.message);
    })
    .finally(() => {
        console.log('Done β€” hide the loading spinner');  // always runs
    });

.finally is perfect for cleanup that must happen regardless of outcome: hiding a loading indicator, closing a connection, re-enabling a button.

Callbacks β†’ Promises

The main reason to hand-write new Promise is to modernize an old callback-based function. Compare the two styles side by side.

Before β€” error-first callback

function fetchUserDataCallback(userId, callback) {
    setTimeout(() => {
        if (!userId) return callback(new Error('Invalid user ID'), null);
        callback(null, { id: userId, name: 'John Doe' });
    }, 1000);
}

After β€” returns a Promise

function fetchUserData(userId) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (!userId) return reject(new Error('Invalid user ID'));
            resolve({ id: userId, name: 'John Doe' });
        }, 1000);
    });
}

// The caller is dramatically cleaner:
fetchUserData(123)
    .then(user => console.log('User:', user))
    .catch(error => console.error('Error:', error.message));

βœ… The general "promisify" wrapper

Node.js even ships a util.promisify helper, but the pattern is easy to write by hand for any error-first function:

function promisify(fn) {
    return (...args) => new Promise((resolve, reject) => {
        fn(...args, (error, result) => {
            error ? reject(error) : resolve(result);
        });
    });
}

// const readFileAsync = promisify(fs.readFile);
// readFileAsync('file.txt', 'utf8').then(console.log).catch(console.error);

Promise Chaining

Here's the payoff. Because every .then returns a new Promise, and because returning a Promise from inside a .then makes the chain wait for it, dependent async steps line up vertically instead of nesting. The same four-step flow that was a pyramid with callbacks becomes a flat, readable ladder.

function fetchUser(id) {
    return new Promise(resolve =>
        setTimeout(() => resolve({ id, name: 'Alice' }), 1000));
}
function fetchPosts(userId) {
    return new Promise(resolve =>
        setTimeout(() => resolve([{ id: 1, userId, title: 'Post 1' }]), 1000));
}
function fetchComments(postId) {
    return new Promise(resolve =>
        setTimeout(() => resolve([{ id: 1, postId, text: 'Great post!' }]), 1000));
}

fetchUser(1)
    .then(user => {
        console.log('User:', user.name);
        return fetchPosts(user.id);          // return a Promise -> chain waits
    })
    .then(posts => {
        console.log('Posts:', posts.length);
        return fetchComments(posts[0].id);
    })
    .then(comments => {
        console.log('Comments:', comments.length);
    })
    .catch(error => {
        console.error('Error somewhere in the chain:', error.message);
    });

⚠️ The number-one chaining bug: forgetting return

If you don't return the inner Promise, the chain doesn't wait for it β€” the next .then receives undefined and runs too early. Always return the Promise (or a value) you want to pass along.

Notice the single .catch at the bottom. A rejection anywhere in the chain skips straight to it β€” one error handler for the whole sequence, instead of one per level.

Combinators: all, race & friends

Chaining runs steps in sequence. When steps are independent, you want them in parallel. The static Promise combinators take an array of Promises and combine them into one.

CombinatorSettles when…Result
Promise.allall fulfill, OR any one rejectsarray of values β€” or the first rejection
Promise.allSettledall settle (never rejects)array of {status, value|reason}
Promise.racethe first one settles (win or lose)that value or reason
Promise.anythe first one fulfillsthat value β€” or AggregateError if all reject

Promise.all β€” everything, together

// Fire three independent requests at once, wait for all of them
Promise.all([fetchUser(1), fetchPosts(1), fetchComments(1)])
    .then(([user, posts, comments]) => {
        console.log('All data loaded:', { user, posts, comments });
    })
    .catch(error => {
        // Rejects as soon as ANY input rejects ("fail fast")
        console.error('At least one request failed:', error.message);
    });

Promise.allSettled β€” everything, report each

// Use when one failure shouldn't sink the others
const jobs = [
    Promise.resolve('Success'),
    Promise.reject(new Error('Failure')),
    Promise.resolve('Another success'),
];

Promise.allSettled(jobs).then(results => {
    results.forEach(r => {
        if (r.status === 'fulfilled') console.log('OK:', r.value);
        else console.log('Failed:', r.reason.message);
    });
});

Promise.race β€” first to finish wins

// Classic use: a timeout guard around a slow request
function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Timed out')), ms));
    return Promise.race([promise, timeout]);   // whichever settles first
}

withTimeout(fetchUser(1), 3000)
    .then(user => console.log('Got user:', user.name))
    .catch(err => console.error(err.message));   // "Timed out" if too slow

Promise.any is the optimistic cousin of race: it ignores rejections and resolves with the first success, handy for "try several mirrors, take whichever responds first."

Error Handling

A single trailing .catch handles a rejection from anywhere earlier in the chain. Inside it you can branch on the error type to respond appropriately.

class NetworkError extends Error {
    constructor(message) { super(message); this.name = 'NetworkError'; }
}
class ValidationError extends Error {
    constructor(errors) { super('Validation failed'); this.name = 'ValidationError'; this.errors = errors; }
}

fetchUser(1)
    .then(user => fetchPosts(user.id))
    .then(posts => fetchComments(posts[0].id))
    .then(comments => console.log('Loaded', comments.length, 'comments'))
    .catch(error => {
        if (error instanceof NetworkError)      showOfflineMessage();
        else if (error instanceof ValidationError) showValidationErrors(error.errors);
        else                                     showGenericError(error);
    });

⚠️ Never leave a Promise uncaught

A rejected Promise with no .catch triggers an "unhandled promise rejection" warning (and can crash a Node process). Always terminate a chain with .catch, or handle errors with try/catch once you move to async/await next lesson.

Practice & Quiz

πŸ‹οΈ Exercise 1: A Promise-based delay

Goal: Write delay(ms) that returns a Promise resolving after ms milliseconds. This tiny helper is one of the most reused utilities in async code.

function delay(ms) {
    // TODO: return a Promise that resolves after ms
}

delay(2000).then(() => console.log('2 seconds have passed'));
πŸ’‘ Hint

You only need resolve. Call setTimeout(resolve, ms) inside the executor β€” there's nothing to reject.

βœ… Solution
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
// Bonus: pass a value through
// const delayValue = (ms, v) => new Promise(r => setTimeout(() => r(v), ms));

πŸ‹οΈ Exercise 2: A chainable calculator

Goal: Build calculate(start) returning an object whose methods each return a Promise-yielding calculator, so operations can chain and end with .getResult().

calculate(5).add(3).multiply(2).subtract(4).divide(2)
    .getResult()
    .then(result => console.log(result));   // 6
πŸ’‘ Hint

Keep a running value. Each method mutates it (after a tiny delay if you want it truly async) and return this so calls chain. getResult() returns Promise.resolve(value).

βœ… Solution
function calculate(start) {
    let value = start;
    const api = {
        add(n)      { value += n; return api; },
        subtract(n) { value -= n; return api; },
        multiply(n) { value *= n; return api; },
        divide(n) {
            if (n === 0) throw new Error('Cannot divide by zero');
            value /= n; return api;
        },
        getResult() { return Promise.resolve(value); },
    };
    return api;
}
// calculate(5).add(3).multiply(2).subtract(4).divide(2).getResult()
//   -> resolves to 6

🎯 Quick Quiz

Question 1: How many times can a single Promise change state?

Question 2: What does Promise.all do if one input Promise rejects?

Question 3: Inside a .then, why must you return a Promise you create?

Best Practices & Pitfalls

βœ… Do

  • Always end a chain with .catch (or handle errors with try/catch under async/await)
  • return the Promise or value you want to pass to the next .then
  • Reject with an Error object, not a string β€” you get a stack trace
  • Use Promise.all to run independent work in parallel instead of awaiting one by one

❌ Don't

  • Nest .then inside .then β€” that recreates the pyramid you escaped
  • Forget return, leaving the next step with undefined
  • Wrap something that's already a Promise in new Promise (the "explicit construction antipattern")
  • Assume order in Promise.all equals completion order β€” the results array matches input order regardless of who finishes first

πŸ’‘ Flatten, don't nest

// ❌ Nested β€” the pyramid sneaks back in
fetchUser(1).then(user => {
    fetchPosts(user.id).then(posts => { /* ... */ });
});

// βœ… Flat β€” return and continue the chain
fetchUser(1)
    .then(user => fetchPosts(user.id))
    .then(posts => { /* ... */ });

Summary

πŸŽ‰ Key Takeaways

  • A Promise represents a future value and lives in one of three states: pending, fulfilled, rejected
  • It settles exactly once β€” later resolve/reject calls are ignored
  • Consume with .then / .catch / .finally; each returns a new Promise, so they chain
  • Returning a Promise from a .then makes the chain wait β€” this flattens callback hell
  • One trailing .catch handles a rejection from anywhere in the chain
  • Run independent work in parallel with Promise.all, allSettled, race, and any

πŸ“š Additional Resources

πŸš€ What's Next?

Promises are clean, but chains of .then still read a little sideways. The next lesson introduces async/await β€” syntactic sugar over Promises that lets you write asynchronous code that reads top-to-bottom like ordinary synchronous code, complete with familiar try/catch error handling.

πŸŽ‰ Excellent!

You can now model any async operation as a Promise, chain dependent steps, and run independent work in parallel. Async/await will make it read even better.