Skip to content
aviral gupta

// I1.2 · ~32 min · Intermediate

From callbacks to promises

After this lesson you turn nested callbacks into a flat promise chain that waits for every step, handles every error in one catch() and cleans up in finally().

Lesson 2 of 6 in I1 Asynchronous JavaScript

You will be able to

  • Name a promise’s states and say when its then() handlers run
  • Chain then() calls that return values and promises, so each step waits for the one before
  • Handle every error once with catch(), go on after it, and clean up with finally()
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I1.1: a callback given to setTimeout. What does this print?

    console.log("order");
    setTimeout(() => console.log("ready"), 0);
    console.log("wait");
  2. Predict · Activity 2 of 7

    Predict before you read on. What does this print?

    const first = Promise.resolve(2);
    const second = first.then((n) => n * 10);
    console.log(first === second);
    second.then((n) => console.log(n));
  3. Practice · Activity 3 of 7

    Fill in the method that handles a rejection anywhere earlier in the chain, so this prints “failed: timeout”.

    Promise.reject(new Error("timeout"))
      .then((data) => console.log("data:", data))
      .____((error) => console.log("failed:", error.message));
    .((error) => console.log("failed:", error.message));
  4. Practice · Activity 4 of 7

    Step 2 throws. Put the lines this program prints in the order they appear.

    Promise.resolve()
      .then(() => console.log("step 1"))
      .then(() => {
        throw new Error("step 2 broke");
      })
      .then(() => console.log("step 3"))
      .catch((error) => console.log("caught:", error.message))
      .then(() => console.log("after the catch"))
      .finally(() => console.log("cleanup"));
    1. 1.step 1
    2. 2.cleanup
    3. 3.after the catch
    4. 4.caught: step 2 broke
  5. Practice · Activity 5 of 7

    The first callback returns a promise. What does the second callback receive?

    const later = (value) => new Promise((resolve) => setTimeout(() => resolve(value), 10));
    
    Promise.resolve("id-7")
      .then((id) => later(`user for ${id}`))
      .then((result) => console.log(typeof result, result));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. save() takes 10 ms. What does this print?

    const save = (x) => new Promise((resolve) => setTimeout(() => resolve(`saved ${x}`), 10));
    
    Promise.resolve("draft")
      .then((doc) => {
        save(doc);
      })
      .then((result) => console.log("result:", result));
  7. Apply · Activity 7 of 7

    Mini-task. Write three nested setTimeout callbacks that print wash, dry and fold, 10 ms apart. Then rewrite them as a flat chain: a helper after(ms, label) that returns a promise of label after ms milliseconds, then() steps that each print and return the next after(), one catch() at the end and a finally() that prints laundry done. Run both versions.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A user and their orders, as a chain

findUser and findOrders each return a promise that settles a little later. The program shows that the call returns a promise at once, then chains the steps: each then() gets the value of the step before, the first returns the next promise so the chain waits for it, and catch() and finally() close the chain. The second half asks for user 2, which does not exist, so the rejection skips a then() and reaches the last catch().

main.js

// Two steps that finish later. Each returns a promise (lesson I1.5 shows how to make one).
function findUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => (id === 1 ? resolve({id, name: "Ada"}) : reject(new Error(`no user ${id}`))), 20);
  });
}
function findOrders(user) {
  return new Promise((resolve) => setTimeout(() => resolve([`${user.name}-A`, `${user.name}-B`]), 10));
}

const pending = findUser(1);
console.log("right after the call:", pending instanceof Promise);

pending
  .then((user) => {
    console.log("user:", user.name);
    return findOrders(user); // return the next promise, so the chain waits for it
  })
  .then((orders) => {
    console.log("orders:", orders);
    return orders.length;
  })
  .then((count) => console.log("count:", count))
  .catch((error) => console.log("failed:", error.message))
  .finally(() => console.log("done with user 1"))
  .then(() => findUser(2))
  .then((user) => console.log("never printed:", user.name))
  .catch((error) => console.log("failed:", error.message));

Run it with

node main.js

Output

right after the call: true
user: Ada
orders: [ 'Ada-A', 'Ada-B' ]
count: 2
done with user 1
failed: no user 2
  • The first line prints before any step: a promise is returned immediately, still pending.
  • orders arrive only because the first callback returned findOrders(user).
  • finally() printed after the successful part and passed the chain on.
  • “never printed” is skipped: the rejection of findUser(2) jumps to the next catch().
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

Return the next step

orderTotal(fetchPrice, fetchShipping) should return a promise of price plus shipping. fetchPrice() returns a promise of the price; fetchShipping(price) returns a promise of the shipping cost for that price. The starter calls both, but its promise is fulfilled with undefined. Make the chain wait for the shipping and return the sum. A rejection of fetchPrice() must still reject orderTotal.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: the total is undefined. What does the outer callback return?

  2. Hint 2

    The callback has braces, so it returns only what a return statement gives it.

  3. Hint 3

    Write return in front of fetchShipping(price).then(…), so the outer then() waits for that promise.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Resolve to price + shipping.
export function orderTotal(fetchPrice, fetchShipping) {
  return fetchPrice().then((price) => {
    return fetchShipping(price).then((shipping) => price + shipping);
  });
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Resolve to price + shipping.
export function orderTotal(fetchPrice, fetchShipping) {
  return fetchPrice().then((price) => {
    fetchShipping(price).then((shipping) => price + shipping);
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {orderTotal} from './main.js';

const later = (value) => new Promise((resolve) => setTimeout(() => resolve(value), 5));
const shipping = (price) => later(price >= 50 ? 0 : 4.5);

test('A price of 20 with 4.50 shipping gives 24.5', async () => {
  const got = await orderTotal(() => later(20), shipping);
  assert.equal(got, 24.5, `orderTotal gave ${got}, expected 24.5`);
});

test('A price of 60 ships free, so the total is 60', async () => {
  const got = await orderTotal(() => later(60), shipping);
  assert.equal(got, 60, `orderTotal gave ${got}, expected 60`);
});

test('A rejected price rejects the total with the same error', async () => {
  const failing = () => Promise.reject(new Error('price service down'));
  await assert.rejects(orderTotal(failing, shipping), {message: 'price service down'}, 'orderTotal should reject with the price error');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

One catch, and a finally

loadName(fetchUser, log) should return a promise of the user’s name in capitals, or GUEST when fetchUser() rejects or the user has no name. Either way it must call log("loaded") exactly once. The starter passes an error handler as then()’s second argument, which does not see errors thrown by the first, and it logs only on success. Use catch() and finally().

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: the user without a name fails with a TypeError. Which handler should see it?

  2. Hint 2

    then(onOk, onFail): onFail handles only the promise then() was called on, not errors thrown in onOk.

  3. Hint 3

    Chain .catch(() => "GUEST") after the then(), and put log("loaded") in .finally(…), which runs on both paths.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Resolve to the name in capitals, or GUEST; call log("loaded") once either way.
export function loadName(fetchUser, log) {
  return fetchUser()
    .then((user) => user.name.toUpperCase())
    .catch(() => "GUEST")
    .finally(() => log("loaded"));
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Resolve to the name in capitals, or GUEST; call log("loaded") once either way.
export function loadName(fetchUser, log) {
  return fetchUser()
    .then((user) => user.name.toUpperCase(), () => "GUEST")
    .then((name) => {
      log("loaded");
      return name;
    });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadName} from './main.js';

const run = async (fetchUser) => {
  const lines = [];
  const name = await loadName(fetchUser, (line) => lines.push(line));
  return {name, lines};
};

test('A user named ada gives ADA and logs loaded once', async () => {
  const {name, lines} = await run(() => Promise.resolve({name: 'ada'}));
  assert.equal(name, 'ADA', `the name was ${name}, expected ADA`);
  assert.deepEqual(lines, ['loaded'], `log got ${JSON.stringify(lines)}, expected ["loaded"]`);
});

test('A rejected fetchUser gives GUEST and logs loaded once', async () => {
  const {name, lines} = await run(() => Promise.reject(new Error('offline')));
  assert.equal(name, 'GUEST', `the name was ${name}, expected GUEST`);
  assert.deepEqual(lines, ['loaded'], `log got ${JSON.stringify(lines)}, expected ["loaded"]`);
});

test('A user without a name gives GUEST and logs loaded once', async () => {
  const {name, lines} = await run(() => Promise.resolve({}));
  assert.equal(name, 'GUEST', `the name was ${name}, expected GUEST`);
  assert.deepEqual(lines, ['loaded'], `log got ${JSON.stringify(lines)}, expected ["loaded"]`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Using the promise as if it were the value

const findUser = (id) => Promise.resolve({id, name: "Ada"});

const user = findUser(1);
console.log(user.name.toUpperCase());

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Why, and the fix

findUser returns a promise, not the user. A promise has no name property, so user.name is undefined. The value only exists inside a then() callback (or after await, lesson I1.3): findUser(1).then((user) => console.log(user.name.toUpperCase())).

A then() callback that forgets to return

Promise.resolve([3, 4])
  .then((items) => {
    items.map((n) => n * 2);
  })
  .then((doubled) => console.log(doubled.length));

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'length')

Why, and the fix

A callback with curly braces returns undefined unless it says return, so the next step gets undefined. Nothing catches the TypeError, so the run stops with it. Write return items.map((n) => n * 2); or drop the braces. The same rule makes a chain wait: return the next promise.

A chain with no catch()

const load = () => Promise.reject(new Error("offline"));

load().then((data) => console.log("data:", data));
console.log("started");

What Node.js prints

Error: offline

Why, and the fix

Without a catch(), a rejection has nowhere to go. Node.js reports an unhandled rejection and stops with exit code 1, after “started” has printed; a browser logs it in the console. End every chain with catch(), or return the chain to a caller that handles it: load().then(…).catch((error) => console.log("failed:", error.message)).

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Callbacks nest

A callback is a function you hand to another function, which calls it later: setTimeout(done, 100) calls done after about 100 ms. When step two needs the result of step one, its call goes inside step one’s callback, step three inside that, and the code drifts to the right. MDN calls this “callback hell” or the “pyramid of doom”, and notes the other cost: you often have to handle errors at every level of the pyramid instead of once.

A promise is a result that arrives later

A promise is an object for the outcome of work that is still running. It starts pending, and settles once: fulfilled with a value or rejected with a reason, usually an Error. After that it never changes. You attach handlers with then(onFulfilled, onRejected). As lesson I1.1 showed, they never run synchronously, not even on a promise that has already settled, and several handlers on one promise run in the order you added them.

Chains: return, catch once, finally

then() returns a new promise right away. It is fulfilled with what your callback returns; if the callback returns a promise, the chain waits for it and the next then() gets its value. So always return the next step: a callback with braces and no return passes undefined on, and its promise is “floating”. A throw or a rejection skips the following then() callbacks and lands in the next catch(). A chain can go on after a catch(). finally() runs either way, gets no argument and passes the result through.

Sources

Last reviewed October 5, 2026