Course outline · 0% complete

0/29 lessons0%

Course overview →

Promises and async/await

lesson 2-4 · ~11 min · 7/29

Callbacks stack up

The callback style from the last two lessons works, but real request handlers chain several slow steps: check the session, then load the user, then save a row. With callbacks, each step must nest inside the previous one, and the error check has to be repeated at every level:

getSession(id, (err, session) => {
  if (err) return handle(err);
  getUser(session.userId, (err, user) => {
    if (err) return handle(err);
    saveOrder(user, (err, order) => {
      if (err) return handle(err);
      // finally, the actual work
    });
  });
});

Code that keeps drifting rightward like this is hard to read, hard to reorder, and easy to get error handling wrong in. JavaScript's answer is the promise: an object that stands for a value that has not arrived yet. A promise is in one of three states: pending (still waiting), fulfilled (the value arrived), or rejected (it failed with an error). Instead of handing a callback in, you get a promise back and attach what should happen next:

  • .then(fn) runs fn with the value once it arrives, and itself returns a new promise, so steps chain flat instead of nesting.
  • .catch(fn) handles a rejection from ANY earlier step in the chain, so one error handler covers everything above it.

This matters daily: nearly every modern Node API returns promises, including fs.promises, database drivers, and fetch (JavaScript's built-in function for making HTTP requests). The callback material still applies, promises are built on the same event loop, but promise style is what you will read and write in current codebases.

A promise chain with one error handler

loadUser returns a promise instead of taking a callback. The first .then receives Ada and starts a second load, which rejects, and the single .catch at the end handles that failure, so the middle step is skipped.

function loadUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 42) resolve({ id: 42, name: "Ada" });
      else reject(new Error("no user with id " + id));
    }, 10);
  });
}

loadUser(42)
  .then((user) => {
    console.log("loaded: " + user.name);
    return loadUser(7);
  })
  .then((user) => console.log("never printed"))
  .catch((err) => console.log("failed: " + err.message));

Output

loaded: Ada
failed: no user with id 7

resolve(value) fulfills the promise so the value goes to .then, and reject(error) rejects it so the error goes to .catch. Those two functions are the only way a promise ever leaves the pending state.

Returning a promise from inside .then makes the chain wait for it, which is how step two follows step one without nesting. Forgetting that return is the most common promise bug, since the chain then continues immediately with undefined and the second load runs unwatched.

The skipped .then is worth understanding rather than just noticing. A rejection travels down the chain past every .then until it meets a .catch, so the middle handler is not called at all, which is what makes one error handler cover three steps.

The chain is flat where the callback version was nested, and that is the whole readability argument. Adding a fourth step appends one .then at the same indentation instead of pushing everything one level deeper.

Note that new Promise appears here because loadUser is wrapping setTimeout, which is callback-based. Wrapping an old-style API is the one place you write new Promise by hand, and code that consumes promise-returning functions never needs it.

async/await: promises, readable

Chained .then calls beat nesting, but JavaScript went one step further with syntax that makes promise code read top to bottom like ordinary code. Inside a function marked async, the keyword await pauses that function until a promise settles, then hands you the fulfilled value. A rejected promise becomes a normal exception, caught with the try/catch you already know:

async function createOrder(id) {
  try {
    const session = await getSession(id);
    const user = await getUser(session.userId);
    return await saveOrder(user);
  } catch (err) {
    // one handler for all three steps
  }
}

Two facts to hold on to:

  • await suspends only the async function it appears in. The event loop from lesson 2-2 keeps running everything else, so the server stays non-blocking while this one handler waits.
  • An async function always returns a promise itself, so callers can await it in turn.

This is the house style of modern Node code and of every framework's documentation, so from here the course shows both styles where it helps.

The same chain in async/await style

The promise chain rewritten with await and one try/catch, producing identical output.

function loadUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 42) resolve({ id: 42, name: "Ada" });
      else reject(new Error("no user with id " + id));
    }, 10);
  });
}

async function main() {
  try {
    const user = await loadUser(42);
    console.log("loaded: " + user.name);
    await loadUser(7);
    console.log("never printed");
  } catch (err) {
    console.log("failed: " + err.message);
  }
}

main();

Output

loaded: Ada
failed: no user with id 7

const user = await loadUser(42) gives the fulfilled value directly, with no callback and no .then, so the variable is available on the next line like any other.

The await on loadUser(7) throws, so execution jumps straight to the catch block and the line after it never runs. That is ordinary exception behavior, which is the point, since a rejected promise becomes a normal error inside an async function.

Comparing this with the .then chain above shows identical behavior in a shape that reads like a straight recipe. The steps are statements in order, the error handling is one familiar block, and no callback boundary separates step one from step two.

main() is called without await at the bottom, because top-level code here is not inside an async function. The call starts the work and returns immediately, and the program stays alive because the pending timer keeps the event loop busy.

Note that an unhandled rejection from main() would be a warning rather than a crash in older Node and a crash in current versions. That is one more reason the try/catch sits inside main rather than being someone else's problem.

What the server does during an 80 ms query

When a handler runs const user = await db.getUser(id); and the database takes 80 ms, only that handler is suspended, and the event loop keeps running callbacks and serving other requests.

await is syntax over promises, and promises ride the same event loop from lesson 2-2. Suspending the handler returns control to the loop, exactly as handing over a callback did, so awaiting I/O never blocks other users.

The word "pause" is what misleads people here. Nothing about the process pauses, and one function's execution is set aside with a note to resume it when the promise settles, which is why ten concurrent requests can all be waiting at once on one thread.

The one thing that still freezes everyone is heavy synchronous computation, because no await ever happens while it runs. A loop over a million records has no suspension point, so the loop holds the thread until it finishes.

There is also a performance trap in the opposite direction. Two independent queries written as two sequential await lines take the sum of their times, and Promise.all runs them together and takes the longer of the two, so awaiting in sequence should mean the second genuinely needs the first.