</> HTML5Advent
ENFRESDEITPT

// js · Web Platform Advent #3

async/await in JavaScript: a practical guide

How async and await work, how they relate to promises, error handling with try/catch, awaiting in loops, and the serial-vs-parallel gotcha — with runnable examples.

A developer's hands typing on a keyboard with colourful source code on the monitor

The async and await keywords are syntax over promises. They let you write asynchronous code that reads top-to-bottom like synchronous code, while still being non-blocking under the hood. If you know promises, you already know async/await — it just removes the .then() ceremony.

The basics

Mark a function async and you can use await inside it. await pauses that function until the promise settles, then gives you the resolved value:

async function getUser(id) {
  const res = await fetch('/api/user/' + id);
  const user = await res.json();
  return user.name;
}

getUser(42).then((name) => console.log(name));

Two things to remember: an async function always returns a promise (whatever you return is wrapped in one), and await only works inside an async function — except at the top level of an ES module, where top-level await is allowed.

async/await vs promises

They're the same machinery. This promise chain…

fetch('/api/user')
  .then((res) => res.json())
  .then((user) => console.log(user.name));

…is exactly equivalent to this async version, which most people find easier to read and debug:

const res = await fetch('/api/user');
const user = await res.json();
console.log(user.name);

You can mix them freely — await any promise, including the result of Promise.all().

Error handling with try/catch

With async/await you catch rejected promises using ordinary try/catch, the same construct you use for synchronous errors:

async function loadUser(id) {
  try {
    const res = await fetch('/api/user/' + id);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } catch (err) {
    console.error('Could not load user:', err.message);
    return null;
  } finally {
    console.log('loadUser finished');
  }
}

A rejected await throws an exception at that line, so the catch block runs. The finally block runs whether or not an error occurred.

A multi-lane motorway with cars and trucks travelling side by side in parallel
Independent tasks can run side by side like vehicles in parallel lanes — awaiting them one at a time forces a single-file queue instead.

The big gotcha: serial vs parallel await

The most common async/await mistake is awaiting independent tasks one after another. This runs in series — the second request doesn't start until the first finishes:

// Slow: total time = a + b + c
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

If the tasks don't depend on each other, start them all first, then await together with Promise.all. Now they run in parallel:

// Fast: total time ≈ the slowest one
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

await in loops

A plain for loop with await processes items one at a time, which is correct when each step depends on the previous one or you must avoid overwhelming a server:

for (const id of ids) {
  const item = await fetchItem(id);   // sequential — one after another
  console.log(item);
}

To process them concurrently, map to an array of promises and await Promise.all. Note that forEach does not wait for async callbacks — use map + Promise.all instead:

const items = await Promise.all(ids.map((id) => fetchItem(id)));

Quick reference

GoalPattern
Wait for one promiseconst x = await p;
Handle errorstry { await ... } catch (e) { ... }
Run independent tasks in parallelawait Promise.all([...])
Sequential loopfor...of with await
Concurrent loopmapPromise.all

async/await doesn't replace promises — it makes them readable. Keep Promise.all in your toolkit for parallelism, reach for try/catch for errors, and watch out for the accidental serial await, and you'll have asynchronous JavaScript well in hand.