JavaScript Async/Await

6 minute read

async and await are syntactic sugar on top of promises. They let you write asynchronous code that reads like synchronous code: top-to-bottom, with normal try/catch for errors. No nested .then chains, no separate error path. If you’ve internalised the promise model from chapter 40, async/await is a tiny conceptual step with an enormous readability payoff.

The Two Keywords

async function loadUser(id) {
  const res  = await fetch(`/api/users/${id}`);
  const user = await res.json();
  return user;
}

const ada = await loadUser(1);
console.log(ada);
  • async before a function makes it return a promise. Whatever you return becomes the resolved value; whatever you throw becomes the rejection.
  • await before a promise pauses the async function until the promise settles, then unwraps it. If the promise rejects, await throws that reason.

What async Really Does

async function a() { return 1; }
async function b() { return Promise.resolve(2); }
async function c() { throw new Error("nope"); }

a().then(v => console.log(v));      // 1
b().then(v => console.log(v));      // 2  (auto-unwrapped)
c().catch(e => console.error(e));   // Error: nope

Equivalents:

async function f() { return x; }
// roughly:
function f() { return Promise.resolve(x); }

async function g() { throw err; }
// roughly:
function g() { return Promise.reject(err); }

The function body still runs synchronously until the first await.

await Pauses; The Event Loop Continues

await doesn’t block the thread. It returns control to the event loop. When the awaited promise settles, the rest of the function is scheduled as a microtask.

async function example() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

console.log("1");
example();
console.log("2");

// Output:
// 1
// A          ← runs sync up to the first await
// 2          ← caller continues
// B          ← microtask after the awaited promise settles

This is exactly the model from chapter 38 — await is Promise.then(continuation) under the hood.

Error Handling — Real try/catch

async function loadDashboard() {
  try {
    const user   = await fetchUser(1);
    const orders = await fetchOrders(user.id);
    render(user, orders);
  } catch (err) {
    showError(err);
  } finally {
    spinner.hide();
  }
}

One try, one catch. Compare with the equivalent .then chain:

function loadDashboard() {
  return fetchUser(1)
    .then(user => fetchOrders(user.id).then(orders => render(user, orders)))
    .catch(showError)
    .finally(() => spinner.hide());
}

The promise version isn’t terrible — but await scales to complex flows (branching, conditionals, loops) far better.

Sequential vs Parallel — The #1 Pitfall

await is sequential. If two awaits don’t depend on each other, you’re wasting time:

// ❌ Sequential — slow
async function load() {
  const user   = await fetchUser(1);     // 200 ms
  const posts  = await fetchPosts(1);    // 200 ms — total 400 ms
}

// ✅ Parallel — start both, then await
async function load() {
  const userP  = fetchUser(1);           // starts immediately
  const postsP = fetchPosts(1);          // starts immediately
  const user   = await userP;
  const posts  = await postsP;           // total ~200 ms
}

// ✅ Even cleaner with Promise.all
async function load() {
  const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
}

Tip: Whenever you see two consecutive awaits for unrelated promises, that’s a chance to parallelise.

Loops With await

The right loop matters.

// ✅ Sequential — process one at a time
for (const item of items) {
  await save(item);
}

// ❌ forEach does NOT await
items.forEach(async item => await save(item));   // all start in parallel; outer fn returns immediately

// ✅ Parallel, all at once
await Promise.all(items.map(item => save(item)));

// ✅ Limited concurrency (3 at a time) — when "all at once" is too aggressive
async function parallel(items, limit, fn) {
  const results = [];
  const executing = [];
  for (const item of items) {
    const p = Promise.resolve().then(() => fn(item));
    results.push(p);
    if (limit <= items.length) {
      const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      if (executing.length >= limit) await Promise.race(executing);
    }
  }
  return Promise.all(results);
}

for await...of — Async Iterables

For streams and generators that yield promises, for await...of is the right loop:

async function* batches() {
  yield await fetch("/page/1").then(r => r.json());
  yield await fetch("/page/2").then(r => r.json());
}

for await (const page of batches()) {
  console.log(page);
}

Works with ReadableStream, async generators (chapter 49), and any object implementing Symbol.asyncIterator.

Top-Level await

In ES modules (and the Node REPL) you can use await directly at the top level — no wrapping async function needed:

// module.mjs
const config = await fetch("/config.json").then(r => r.json());
console.log(config);

In a regular <script> (non-module) you still need an async wrapper:

(async () => {
  const config = await fetch("/config.json").then(r => r.json());
  console.log(config);
})();

Mixing await and Promise Methods

You can — and often should:

// Parallel + await result
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

// Timeout via race
const result = await Promise.race([
  doWork(),
  new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);

// Survive partial failures
const results = await Promise.allSettled(items.map(load));
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);

Returning Vs Awaiting

return await x is almost identical to return x inside an async function. Differences:

  • return x returns the promise directly to the caller
  • return await x waits for it inside this function, then returns its value

The visible difference: error-handling boundaries. return await x inside try will catch the rejection here; return x inside try will not.

async function inside() {
  try {
    return await failing();   // catches here
  } catch (e) {
    return "fallback";
  }
}

async function outside() {
  try {
    return failing();         // ❌ does NOT catch — rejection bubbles to caller
  } catch (e) {
    return "fallback";
  }
}

Tip: Use return await inside try blocks so the local catch actually catches the rejection.

Cancellation — AbortController

Same pattern as chapter 40 — await doesn’t change cancellation, you still need an AbortSignal:

async function loadWithTimeout(url, ms = 5000) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), ms);
  try {
    const res = await fetch(url, { signal: ctrl.signal });
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

A Realistic Example — Pipeline With Recovery

async function publishArticle(article) {
  try {
    const draft = await db.saveDraft(article);

    // Run independent steps in parallel
    const [validated, indexed] = await Promise.all([
      validator.check(draft),
      search.index(draft),
    ]);

    if (!validated.ok) {
      throw new Error(`Validation failed: ${validated.errors.join(", ")}`);
    }

    const published = await db.markPublished(draft.id);
    await notifier.notify({ event: "article:published", id: published.id });
    return { ok: true, id: published.id };

  } catch (err) {
    console.error("publish failed", err);
    await rollback(article).catch(rbErr => console.error("rollback also failed", rbErr));
    return { ok: false, error: err.message };
  }
}

Sequential where ordering matters, parallel where it doesn’t, single try/catch, structured return.

Common Mistakes

// ❌ Forgetting async
function load() {
  const x = await fetchX();   // SyntaxError — await outside async
}

// ❌ Forgetting await — `result` is a Promise, not the value
async function f() {
  const result = fetchX();
  console.log(result);        // [object Promise]
}

// ❌ Awaiting in a tight serial loop when parallel is fine
for (const id of ids) await fetchUser(id);   // slow

// ✅
const users = await Promise.all(ids.map(fetchUser));

// ❌ Swallowing errors silently
async function f() {
  try { await dangerous(); } catch {}   // empty catch = silent failure
}

// ❌ Async constructors don't exist
class C {
  async constructor() {}     // ❌ SyntaxError
}
// ✅ Static factory:
class C {
  static async create() { const c = new C(); await c.init(); return c; }
}

Best Practices Checklist

  • ✅ Reach for async/await over manual .then chains in new code
  • ✅ Use one try/catch per logical block, not per await
  • ✅ Start independent promises before awaiting (or use Promise.all)
  • ✅ Use for…of (or for await…of) — never forEach — when awaiting per item
  • ✅ Use return await inside try so local catch actually runs
  • ✅ Use AbortController for cancellation/timeouts
  • ❌ Don’t await unrelated promises in sequence
  • ❌ Don’t catch errors silently
  • ❌ Don’t forget to await — silent Promise returns lurk in your data

What’s Next?

Async fluency unlocked. Time for the most common use of it on the web — the Fetch API — for talking to HTTP servers. Next chapter covers requests, responses, headers, JSON, errors, and cancellation.

← Previous: JS Promises Next: JS Fetch API →

Updated: