⚡

JavaScript Promises and Async/Await: A Practical Guide

JavaScript3 min readUpdated October 2026

Understand JavaScript promises and async/await step by step, including running tasks in parallel with Promise.all and the mistakes that cause silent bugs.

Some things take time: loading data from a server, reading a file, waiting for a timer. JavaScript doesn't stop and wait for them. It keeps running your other code and comes back when the slow thing is done.

That's useful, but it changes how you write code. This guide walks through the three ways to handle it, and why you'll almost always want the last one.

The problem

Look at this code. What do you expect it to print?

console.log("1: start");

setTimeout(() => {
  console.log("2: timer finished");
}, 100);

console.log("3: end");
1: start
3: end
2: timer finished

The timer callback runs last, even though it appears in the middle. JavaScript schedules it and moves on. Everything about async code comes from this one fact.

Callbacks: the old way

The first solution was to pass a function that gets called when the work finishes.

function getUser(id, callback) {
  setTimeout(() => callback({ id, name: "Ana" }), 50);
}

function getOrders(userId, callback) {
  setTimeout(() => callback(["book", "pen"]), 50);
}

getUser(1, (user) => {
  getOrders(user.id, (orders) => {
    console.log(user.name, "ordered", orders.join(" and "));
  });
});
Ana ordered book and pen

It works, but each new step nests deeper. Add error handling at every level and you get the famous "callback pyramid." It's hard to read and harder to debug.

Promises: a value that arrives later

A promise is an object that stands in for a result you don't have yet. It's always in one of three states: pending, fulfilled (it worked), or rejected (it failed).

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

function getUser(id) {
  return wait(50).then(() => ({ id, name: "Ana" }));
}

function getOrders(userId) {
  return wait(50).then(() => ["book", "pen"]);
}

getUser(1)
  .then((user) => getOrders(user.id).then((orders) => ({ user, orders })))
  .then(({ user, orders }) => {
    console.log(user.name, "ordered", orders.join(" and "));
  })
  .catch((error) => console.error("Something failed:", error));
Ana ordered book and pen

The chain is flat, and a single .catch() handles an error from any step. That's already a big improvement.

async/await: promises that read like normal code

async and await are a cleaner way to use promises. Inside an async function, await pauses that function until the promise settles, then gives you the value.

async function showOrders() {
  const user = await getUser(1);
  const orders = await getOrders(user.id);
  console.log(user.name, "ordered", orders.join(" and "));
}

showOrders();
Ana ordered book and pen

Same result, but it reads top to bottom. Errors work with the try/catch you already know:

async function risky() {
  try {
    await Promise.reject(new Error("server down"));
  } catch (error) {
    console.log("Caught:", error.message);
  }
}

risky();
Caught: server down

Two facts to remember: an async function always returns a promise, and await only works inside an async function (or at the top level of a module).

Run things in parallel with Promise.all

Here's a mistake that makes apps slower than they need to be. These two requests don't depend on each other, but the code runs them one after the other:

async function slow() {
  const start = Date.now();
  await wait(300);
  await wait(300);
  console.log("sequential:", Math.round((Date.now() - start) / 100) * 100, "ms");
}

async function fast() {
  const start = Date.now();
  await Promise.all([wait(300), wait(300)]);
  console.log("parallel:", Math.round((Date.now() - start) / 100) * 100, "ms");
}

slow().then(fast);
sequential: 600 ms
parallel: 300 ms

If the tasks are independent, start them all first and wait for them together. The total time is the slowest one, not the sum.

Choosing the right helper

  • Promise.all waits for everything and fails fast. If any promise rejects, the whole thing rejects.
  • Promise.allSettled waits for everything and never rejects. You get a report on each one.
  • Promise.race settles as soon as the first promise does. Useful for timeouts.
  • Promise.any resolves with the first one that succeeds.
async function demo() {
  const results = await Promise.allSettled([
    Promise.resolve("ok"),
    Promise.reject(new Error("nope")),
  ]);
  results.forEach((r) => console.log(r.status));
}

demo();
fulfilled
rejected

Use allSettled when partial success is acceptable, such as loading several widgets on a dashboard where one failing shouldn't blank the page.

Mistakes that cause silent bugs

Forgetting await. You get a promise object instead of the value, and no error.

const user = getUser(1);       // promise, not a user
console.log(user.name);        // undefined

Using await inside forEach. forEach doesn't wait for async callbacks. Use a for...of loop for sequential work, or Promise.all with map for parallel work.

async function loop() {
  const ids = [1, 2, 3];
  for (const id of ids) {
    const user = await getUser(id);
    console.log("loaded", user.id);
  }
}

loop();
loaded 1
loaded 2
loaded 3

Unhandled rejections. If a promise fails and nothing catches it, you'll see an error in the console or the failure gets lost. Always have a catch somewhere.

Not checking response.ok with fetch. fetch only rejects on network failure. A 404 or 500 still resolves, so check if (!response.ok) yourself.

Key takeaways

  • Async code doesn't block. JavaScript schedules it and keeps going.
  • Prefer async/await. It's the same promises with far better readability.
  • Run independent tasks together with Promise.all.
  • Always handle errors, and never forget await.

Try it yourself

Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.

Open the JavaScript editor

Related Articles