Promises and async/await

Asynchronous work, error handling, and the model behind data fetching.

What you'll learn

  • What a promise represents, and its three states
  • Reading .then chains, and writing the same thing with async/await
  • Handling errors in both styles
  • Running work in parallel instead of one after another

The problem

JavaScript runs on a single thread. If it waited for a network request, the page would freeze. So slow work is started now and finishes later, and you describe what should happen when it does.

A promise is the object representing that eventual result. It is in one of three states: pending, fulfilled with a value, or rejected with an error.

Note the ordering below — the last line runs before the promise resolves:

const promise = new Promise((resolve) => {
  setTimeout(() => resolve("done"), 500);
});

console.log("promise is:", promise);

promise.then((value) => console.log("resolved with:", value));

console.log("this line runs first");

then and catch

.then registers what to do with the value. Returning from a .then passes the result to the next one, which is what makes chains work. .catch handles a rejection anywhere earlier in the chain.

function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

delay(2, 300)
  .then((n) => {
    console.log("got", n);
    return n * 10;
  })
  .then((n) => {
    console.log("then", n);
  })
  .catch((error) => {
    console.log("failed:", error.message);
  })
  .finally(() => {
    console.log("finally always runs");
  });

async and await

async/await is the same machinery with flatter syntax. An async function always returns a promise, and await pauses inside it until a promise settles.

function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function run() {
  console.log("start");

  const first = await delay("one", 300);
  console.log(first);

  const second = await delay("two", 300);
  console.log(second);

  return "finished";
}

run().then((result) => console.log(result));

Note that run() still returns a promise — await only pauses inside the async function, never the program around it.

Errors with try/catch

Because await unwraps the promise, a rejection surfaces as a thrown error, and ordinary try/catch works:

function failAfter(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error("network down")), ms);
  });
}

async function load() {
  try {
    await failAfter(300);
    console.log("never reached");
  } catch (error) {
    console.log("caught:", error.message);
  } finally {
    console.log("cleanup");
  }
}

load();

An unhandled rejection is a real bug — always have a catch, in either style.

Sequential vs parallel

This is the mistake worth internalising. Two awaits in a row run one after the other. If the tasks do not depend on each other, that is wasted time.

Start them both, then await both — Promise.all takes an array of promises and resolves with an array of results:

function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function sequential() {
  const start = Date.now();
  const a = await delay("a", 400);
  const b = await delay("b", 400);
  console.log(a, b, "sequential took about", Date.now() - start, "ms");
}

async function parallel() {
  const start = Date.now();
  const both = await Promise.all([delay("a", 400), delay("b", 400)]);
  console.log(both.join(" "), "parallel took about", Date.now() - start, "ms");
}

sequential().then(parallel);

Roughly 800ms against 400ms, for the same work.

Promise.all rejects as soon as any one of its promises rejects. When you want every result regardless, Promise.allSettled gives you a status for each.

Fetching data

Real code mostly awaits fetch. The shape below is the one you will reuse in Fetching Data once components are involved:

async function loadUser(id) {
  const response = await fetch("/api/users/" + id);

  // fetch only rejects on network failure, not on 404 or 500.
  if (!response.ok) {
    throw new Error("Request failed with " + response.status);
  }

  return response.json(); // also a promise
}

That response.ok check is easy to forget. A 404 is a successful HTTP exchange as far as fetch is concerned.

Common mistakes

  • Forgetting await. You get a pending promise instead of a value, and it usually shows up as undefined somewhere later.
  • Awaiting in sequence for no reason. Use Promise.all for independent work.
  • Assuming fetch throws on 404. Check response.ok yourself.
  • No rejection handling. Every chain needs a catch, or the async call needs a try.

Exercise

loadAll fetches three things one after another and takes about 900ms. Make it run them in parallel so it takes about 300ms, keeping the same output order.

function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function loadAll() {
  const start = Date.now();

  const user = await delay("user", 300);
  const posts = await delay("posts", 300);
  const tags = await delay("tags", 300);

  console.log([user, posts, tags]);
  console.log("took about", Date.now() - start, "ms");
}

loadAll();