Step 08 of 21

Async JavaScript

Understand the event loop, write clear async code with promises and async/await, and fetch data with proper error handling.

20 min 9 sections

Loading data, waiting for a timer, reading a file – these take time, and JavaScript cannot simply stop and wait without freezing the whole page. Asynchronous code lets you start slow work, carry on, and handle the result when it arrives. In this lesson you will learn how the event loop schedules that work, how promises represent future values, how async/await makes async code read like normal code, and how to use fetch without the mistakes that cause most real-world bugs.

Why JavaScript needs async

JavaScript in a page runs on a single main thread, which it shares with rendering and input handling. While your code runs, nothing else on the page can happen. Try this in a console on a page with a button:

js
const end = Date.now() + 3000;
while (Date.now() < end) {
  // busy-wait for 3 seconds
}
console.log("done");

For three seconds the page is frozen: clicks do nothing, animations stop, text cannot be selected. A network request can take far longer than that, so APIs like timers and fetch work differently. You start the operation, the browser does the waiting in the background, and your code is called back when it finishes.

The event loop

Several pieces work together:

  • The call stack holds the functions currently running. JavaScript runs one thing at a time, to completion.
  • Browser APIs (timers, network, events) do their waiting outside JavaScript.
  • When they finish, they queue a callback. Callbacks from timers, events and network go into the task queue. Promise reactions go into the microtask queue.
  • The event loop repeats forever: take one task and run it to completion; then run every microtask, including ones queued along the way; then let the browser render if it needs to; repeat.

That ordering explains output that surprises almost everyone the first time:

js
console.log("1: script start");

setTimeout(() => console.log("2: timeout"), 0);

Promise.resolve().then(() => console.log("3: microtask"));

console.log("4: script end");
text
1: script start
4: script end
3: microtask
2: timeout

The synchronous code finishes first. Then the microtask queue is drained, so the promise callback runs. Only then does the event loop take the next task – the timer – even though its delay was 0.

Step through the same program below (with the labels shortened to letters), then try two harder ones. Watch where each callback waits: timer callbacks sit in Web APIs until they expire and then join the task queue, promise callbacks go to the microtask queue, and the microtask queue is always emptied before the next task starts.

InteractiveThe event loop, step by stepOpen in Labs
Loading interactive…

In the async/await program, notice that the code after await is queued as a microtask. In the nested program, a microtask queued inside a task still runs before the next task.

Callbacks, and why promises replaced them

The original async style passed a callback to be called later:

js
setTimeout(() => {
  console.log("step 1");
  setTimeout(() => {
    console.log("step 2");
    setTimeout(() => {
      console.log("step 3");
    }, 1000);
  }, 1000);
}, 1000);

Each dependent step nests deeper, and every level needs its own error handling. This "callback pyramid" is hard to read and easy to get wrong, which is why modern APIs return promises instead.

Promises

A promise is an object representing a value that will be available later. It is always in one of three states:

  • pending – the work is still in progress;
  • fulfilled – it succeeded, with a value;
  • rejected – it failed, with a reason (usually an Error).

A promise settles once. After it is fulfilled or rejected, it never changes again.

Consuming promises

.then() registers what to do with the value, .catch() handles a rejection, and .finally() runs either way. Each returns a new promise, so calls chain without nesting:

js
fetch("/api/user")
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();          // returning a promise: the chain waits for it
  })
  .then((user) => console.log(user.name))
  .catch((error) => console.error("Could not load user:", error))
  .finally(() => console.log("request finished"));

Whatever a .then callback returns becomes the value for the next .then. If it throws – or anything earlier in the chain rejects – control skips to the nearest .catch.

Creating promises

You rarely need to construct a promise yourself, because APIs like fetch return them. The exception is wrapping an old callback-based API. The classic example turns setTimeout into a promise:

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(1000).then(() => console.log("one second later"));

The function passed to new Promise receives resolve and reject. Call resolve(value) to fulfil the promise or reject(error) to reject it.

async and await

async/await is syntax built on promises that lets you write asynchronous code top to bottom:

js
async function loadUser() {
  try {
    const response = await fetch("/api/user");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    console.log(user.name);
  } catch (error) {
    console.error("Could not load user:", error);
  } finally {
    console.log("request finished");
  }
}

The rules:

  • await pauses the async function until the promise settles, then gives you the fulfilled value – or throws the rejection reason, so try/catch works naturally.
  • Pausing does not block the thread. While the function waits, the event loop keeps handling clicks and rendering.
  • An async function always returns a promise. Returning 42 from it gives the caller a promise of 42; throwing inside rejects that promise.
  • await only works inside async functions, and at the top level of ES modules.
  • An async function runs synchronously until its first await:
js
async function demo() {
  console.log("B");
  await null;
  console.log("D");
}

console.log("A");
demo();
console.log("C");
text
A
B
C
D

Sequential versus parallel

await in sequence is right when each step needs the previous result. When steps are independent, awaiting them one by one wastes time:

js
// Sequential: about 300 + 300 + 300 = 900 ms
const a = await getUser(1);
const b = await getUser(2);
const c = await getUser(3);

// Parallel: start all three, then wait for all – about 300 ms
const [x, y, z] = await Promise.all([getUser(1), getUser(2), getUser(3)]);
CombinatorSettles whenResult
Promise.allAll fulfil, or any one rejectsArray of values, or the first rejection
Promise.allSettledAll have settledArray of { status, value } or { status, reason }
Promise.raceThe first one settlesThat value or rejection
Promise.anyThe first one fulfilsThat value, or an AggregateError if all reject

Use all when you need every result, allSettled when partial success is acceptable (a dashboard with independent widgets), race for timeouts, and any for "whichever mirror answers first".

Fetching data

fetch(url, options) makes an HTTP request and returns a promise of a Response. Reading the body is a second async step, because the body may still be streaming in:

js
async function getJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} for ${url}`);
  }
  return response.json();
}

Other body methods are response.text() and response.blob(). Each can be read only once.

To send data, set the method, headers and body:

js
const response = await fetch("/api/orders", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ productId: 42, qty: 1 }),
});

Cancelling requests and timeouts

An AbortController cancels a request. This matters for search-as-you-type: if the user types "sho" and then "shoe", the slower "sho" response could arrive last and overwrite the correct results. Aborting the previous request prevents that race:

js
const input = document.querySelector("#search");
let controller;

input.addEventListener("input", async () => {
  controller?.abort();                 // cancel the previous request
  controller = new AbortController();

  try {
    const url = `/api/search?q=${encodeURIComponent(input.value)}`;
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    renderResults(await response.json());
  } catch (error) {
    if (error.name === "AbortError") return; // expected: we cancelled it
    showError(error);
  }
});

For a simple timeout, AbortSignal.timeout(ms) creates a signal that aborts automatically; the request then rejects with an error named "TimeoutError":

js
const response = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });

Common pitfalls

Forgetting await. const user = getUser(1) gives you a pending promise, not a user, so user.name is undefined. If a value prints as Promise { <pending> }, you missed an await.

await inside forEach. forEach ignores the promises its callback returns, so nothing waits:

js
ids.forEach(async (id) => await save(id));
console.log("all saved"); // runs before any save finishes

for (const id of ids) await save(id);       // fix 1: one at a time
await Promise.all(ids.map((id) => save(id))); // fix 2: all at once

Unhandled rejections. A rejected promise that nobody catches logs an "Uncaught (in promise)" error and your error handling never runs. Every chain needs a .catch, and every await that can fail needs a try/catch somewhere up the call chain.

Mixing styles. Combining .then chains and await in one function makes control flow hard to follow. Pick async/await for new code.

Practice

Save this simulated API as async-practice.mjs (the .mjs extension enables top-level await in Node), then write the four functions below it and run node async-practice.mjs.

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const users = { 1: "Ana", 2: "Ben", 3: "Cleo" };

async function getUser(id) {
  await wait(300);
  if (!users[id]) throw new Error(`User ${id} not found`);
  return { id, name: users[id] };
}

async function getPosts(userId) {
  await wait(500);
  return [`Post A by user ${userId}`, `Post B by user ${userId}`];
}
  1. loadProfile(id) returns { user, posts }. It must get the user first (it fails for unknown ids), then the posts.
  2. loadUsers(ids) fetches all users in parallel and returns their names. Confirm loadUsers([1, 2, 3]) takes about 300 ms, not 900 ms.
  3. loadUsersSafely(ids) never rejects; it returns { names, failed } where failed lists the ids that could not be loaded. Try [1, 99, 3].
  4. withTimeout(promise, ms) rejects with Error("Timed out after <ms>ms") if promise takes longer than ms. Check that withTimeout(getPosts(1), 200) rejects and withTimeout(getPosts(1), 1000) fulfils.
Show solution
js
async function loadProfile(id) {
  const user = await getUser(id);       // must come first
  const posts = await getPosts(user.id);
  return { user, posts };
}

async function loadUsers(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  return users.map((user) => user.name);
}

async function loadUsersSafely(ids) {
  const results = await Promise.allSettled(ids.map((id) => getUser(id)));
  const names = [];
  const failed = [];
  results.forEach((result, i) => {
    if (result.status === "fulfilled") names.push(result.value.name);
    else failed.push(ids[i]);
  });
  return { names, failed };
}

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

console.log(await loadProfile(2));

console.time("loadUsers");
console.log(await loadUsers([1, 2, 3]));
console.timeEnd("loadUsers");

console.log(await loadUsersSafely([1, 99, 3]));

try {
  await withTimeout(getPosts(1), 200);
} catch (error) {
  console.log(error.message);
}
console.log(await withTimeout(getPosts(1), 1000));
text
{
  user: { id: 2, name: 'Ben' },
  posts: [ 'Post A by user 2', 'Post B by user 2' ]
}
[ 'Ana', 'Ben', 'Cleo' ]
loadUsers: 301.512ms
{ names: [ 'Ana', 'Cleo' ], failed: [ 99 ] }
Timed out after 200ms
[ 'Post A by user 1', 'Post B by user 1' ]

Your exact timing will differ slightly, but it should be close to 300 ms because all three requests wait at the same time. allSettled keeps the results in the same order as the input, which is why ids[i] identifies the failure. In withTimeout, finally clears the timer so a fast promise does not leave a pending timeout behind.

Key takeaways

  • JavaScript runs on one thread; async APIs do the waiting elsewhere and queue callbacks for the event loop.
  • After each task, all microtasks (promise reactions) run before the next task, so promises beat setTimeout(…, 0).
  • A promise is pending, then fulfilled or rejected exactly once; .then, .catch and .finally chain.
  • async functions always return promises; await pauses only that function and throws on rejection, so use try/catch.
  • Run independent work in parallel with Promise.all or Promise.allSettled instead of awaiting one at a time.
  • fetch does not reject on HTTP errors – check response.ok – and use AbortController to cancel stale requests.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What order does this log? console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => console.log('c')); console.log('d');

  2. 2.A fetch to /api/items returns a 404 status. What happens to the promise returned by fetch?

  3. 3.Three independent requests each take about 400 ms. Which code finishes in about 400 ms in total?

  4. 4.What does calling an async function that contains return 42 give the caller?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.