webdev.complete
Async JavaScript
JavaScript
Lesson 32 of 117
35 min

Promises & async/await

Chaining, error handling, parallel vs sequential, withResolvers.

A promise is a value that's "not here yet, but will be." Async/await is fancy syntax that lets you write code that uses promises as if it were synchronous. Together they replaced the callback nightmare of 2010s JavaScript with something you can actually read.

Promise states

Every promise is in exactly one of three states:

  • pending - work is still in flight
  • fulfilled- work succeeded, here's a value
  • rejected- work failed, here's the reason

A promise moves from pending to fulfilled or rejected exactly once, then never changes again.

Creating and consuming a promise

js
// Most of the time you get promises from APIs (fetch, etc.)
// But you can build your own:
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done"), 500);
});

// Consume with .then / .catch / .finally
p
  .then(value => console.log("got:", value))
  .catch(err   => console.error("oops:", err))
  .finally(()  => console.log("cleanup"));

.then returns a new promise, so you can chain. Each .then waits for the previous one to resolve and passes its return value down.

async / await: the readable version

An async function always returns a promise. await pauses inside it until a promise resolves. The same code with promises and with async/await:

js
// .then chain
function loadUser(id) {
  return fetch("/api/users/" + id)
    .then(r => r.json())
    .then(user => fetch("/api/orders/" + user.id))
    .then(r => r.json());
}

// async/await
async function loadUser(id) {
  const userRes  = await fetch("/api/users/" + id);
  const user     = await userRes.json();
  const orderRes = await fetch("/api/orders/" + user.id);
  return orderRes.json();
}

Error handling with try/catch

js
async function load(id) {
  try {
    const res = await fetch("/api/users/" + id);
    if (!res.ok) throw new Error("Bad status: " + res.status);
    return await res.json();
  } catch (err) {
    console.error("failed:", err);
    return null;
  }
}
try/catch around await catches both thrown errors and rejected promises. One pattern for both.

The sequential await anti-pattern

Each awaitwaits for the previous one. If your awaits don't depend on each other, that's a problem:

js
// SLOW: 3 seconds total (1s + 1s + 1s)
async function loadAllSlow() {
  const a = await fetch("/a");   // wait 1s
  const b = await fetch("/b");   // then wait 1s
  const c = await fetch("/c");   // then wait 1s
  return [a, b, c];
}

// FAST: ~1 second total (all three in parallel)
async function loadAllFast() {
  const [a, b, c] = await Promise.all([
    fetch("/a"),
    fetch("/b"),
    fetch("/c"),
  ]);
  return [a, b, c];
}

Promise.all, allSettled, race, any

js
// all: resolves when ALL succeed, rejects on first failure
Promise.all([p1, p2, p3])

// allSettled: never rejects - gives an array of { status, value/reason }
Promise.allSettled([p1, p2, p3]).then(results => {
  results.forEach(r => {
    if (r.status === "fulfilled") console.log("ok:", r.value);
    else                          console.log("err:", r.reason);
  });
});

// any: resolves with first SUCCESS, only rejects if all fail
Promise.any([p1, p2, p3])

// race: resolves OR rejects with first to settle (good for timeouts)
Promise.race([p1, p2, p3])
Which one when?
  • all - must all succeed (e.g. parallel data fetch).
  • allSettled - "try everything, tell me what happened" (e.g. batch jobs).
  • any - first one that works (e.g. fallback mirrors).
  • race - first one to settle, win or lose (e.g. timeout vs fetch).

Promise.withResolvers: a new convenience

Sometimes you want a promise plus the resolve and rejecthandles to call later from somewhere else. Before, you had to capture them inside the constructor. Now there's a one-liner (ES2024):

js
const { promise, resolve, reject } = Promise.withResolvers();

button.addEventListener("click", () => resolve("clicked!"));

const result = await promise;

Try it: parallel vs sequential timing

The playground below has a sleep helper. Run it and watch the timings. Then try changing it to run jobs sequentially and see the total go up.

// Helper: a promise that resolves after ms milliseconds
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

async function loadItem(name, ms) {
  await sleep(ms);
  return name + " done";
}

async function sequential() {
  const start = performance.now();
  const a = await loadItem("A", 500);
  const b = await loadItem("B", 500);
  const c = await loadItem("C", 500);
  const elapsed = (performance.now() - start).toFixed(0);
  console.log("sequential:", [a, b, c], "in", elapsed, "ms");
}

async function parallel() {
  const start = performance.now();
  const [a, b, c] = await Promise.all([
    loadItem("A", 500),
    loadItem("B", 500),
    loadItem("C", 500),
  ]);
  const elapsed = (performance.now() - start).toFixed(0);
  console.log("parallel:  ", [a, b, c], "in", elapsed, "ms");
}

(async () => {
  await sequential();   // ~1500ms
  await parallel();     // ~500ms

  // Try allSettled with a deliberate failure
  const results = await Promise.allSettled([
    sleep(100).then(() => "ok"),
    sleep(200).then(() => { throw new Error("boom"); }),
    sleep(300).then(() => "also ok"),
  ]);
  console.log("\nallSettled:");
  results.forEach((r, i) => {
    console.log(" ", i, r.status, r.value ?? r.reason.message);
  });
})();

Quiz

Quiz1 / 4

What does an `async` function always return?

Recap

  • Promises move from pending to fulfilled or rejected exactly once.
  • async functions return promises; await unwraps them.
  • Use try/catch with await for error handling.
  • Don't await independent jobs in sequence. Use Promise.all for parallel.
  • all, allSettled, any, race each have a clear job. Pick the one that matches your intent.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.