Asynchronous JavaScript: event loop, promises, async/await

Intermediate

Introduction

If JavaScript stopped and waited for each network request, the whole page would freeze until the answer arrived. Instead, it starts slow work and carries on. This makes programs responsive, but the order in which things happen is no longer the order of the lines. This lesson gives you the exact rules, so that the order stops being a mystery.

How it works

JavaScript runs on one thread: it does one thing at a time. Slow work (a timer, a network request, a file) is handed to the browser or Node.js, and JavaScript carries on. When that work finishes, a callback is put in a queue, and the event loop runs it once the current code has finished.

The output order surprises everyone the first time:

  1. All the ordinary (synchronous) code runs first, to the end.
  2. Then every microtask: the callbacks of promises that have settled.
  3. Then the tasks (also called macrotasks): timers, clicks, network events, one at a time, each followed by any new microtasks.

So even setTimeout(fn, 0) runs after the promise callbacks. The visualizer marks each turn of the event loop.

Promises. A promise is an object for a value that is not ready yet. It is pending, then either fulfilled with a value or rejected with an error. .then(fn) runs fn with the value, and .catch(fn) runs fn with the error. Each returns a new promise, so they chain.

async and await. An async function always returns a promise. Inside it, await promise pauses that function (not the whole program) until the promise settles, then gives its value, or throws its error so that an ordinary try/catch handles it:

// async functions and await, with timers standing in for slow work.
function wait(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function loadUser() {
  console.log("Loading...");
  const user = await wait(300, { name: "Mitu" });
  const posts = await wait(200, ["Hello", "Week 2"]);
  return `${user.name} has ${posts.length} posts`;
}

async function main() {
  const summary = await loadUser();
  console.log(summary);
  const both = await Promise.all([wait(100, "a"), wait(50, "b")]);
  console.log(both);
  try {
    await Promise.reject(new Error("server is down"));
  } catch (err) {
    console.log("Caught:", err.message);
  }
}

main();
console.log("main has started");
Open in compiler

Promise.all([p1, p2]) waits for several promises at once and gives an array of their values. It takes only as long as the slowest one, which is why it beats awaiting each job in turn.

fetch. In a browser, fetch(url) asks a server for data and returns a promise of the response. StepVioz programs run without a network, so here fetch always rejects, and the examples use timers to stand in for slow work. A later lesson covers fetching in detail.

Syntax

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

async function main() {
  try {
    await wait(100);
    console.log("done");
  } catch (err) {
    console.log("failed:", err.message);
  }
}
main();

Example

// Which runs first: timers, promises or ordinary code?
console.log("1: start");
setTimeout(() => console.log("4: timer"), 0);
Promise.resolve().then(() => console.log("3: promise"));
console.log("2: end of the script");
Open in compiler

Line by line

  1. "1: start" is printed immediately.
  2. setTimeout schedules a task and returns at once. Nothing is printed.
  3. Promise.resolve().then(...) schedules a microtask. Nothing is printed.
  4. "2: end of the script" is printed, and the script is finished.
  5. The microtask queue is emptied, which prints the promise line.
  6. The timer's task runs last, even though its delay was 0.

Expected result

1: start
2: end of the script
3: promise
4: timer

In the real world

Loading a page's data, saving a form and uploading a photo are all asynchronous. Code that makes several requests often uses Promise.all so that a dashboard loads in the time of its slowest request, rather than the total of all of them.

Common mistakes

  • Forgetting await. You get the promise itself, not its value.
  • Using await outside an async function. In an ordinary script that is a SyntaxError. Put the code in an async function and call it. (Only JavaScript modules allow await at the top level.)
  • Expecting setTimeout(fn, 0) to run before the code after it.
  • A promise chain with no .catch, or an await with no try. A failure becomes an unhandled rejection.
  • Awaiting in a loop one by one when the jobs could run together with Promise.all.

Best practices

  • Prefer async and await to long .then chains, because they read from top to bottom.
  • Handle errors at the level that can tell the user something useful.
  • Use Promise.all for independent jobs, and sequential await only when one step needs the last.

Summary

JavaScript does one thing at a time, and the event loop runs callbacks when it is free: first synchronous code, then microtasks, then tasks. Promises represent future values. async and await let you write asynchronous steps almost like ordinary code.

Quick check

1. console.log(1); setTimeout(() => console.log(2), 0); console.log(3); prints:
2. Which runs first after the synchronous code ends?
3. What does an async function return?
4. What does await do?

Key takeaways

  • Synchronous code always finishes first, then microtasks, then tasks.
  • await pauses only its own function, and the rest of the program carries on.
  • Handle failures with try/catch around await, or with .catch.

Practice problems for this lesson

My notes

Write down what you want to remember from this lesson. Notes are saved on this device as you type, and appear on your dashboard.