JavaScript async/await Explained: How It Really Works (and How to Use It Well)

Link copied
JavaScript async/await Explained: How It Really Works (and How to Use It Well)

JavaScript async/await Explained: How It Really Works (and How to Use It Well)

JS Tutorial Module 5: Async Lesson 5.3

async/await is the syntax that finally made asynchronous JavaScript read like synchronous JavaScript. It's not a new mechanism — it's syntactic sugar over Promises (Lesson 5.2). But it's such a big readability improvement that it changed how every modern JS codebase is structured.

This lesson covers exactly what async and await do under the hood, the patterns for sequential vs parallel work, error handling with try/catch, the top-level await feature, common bugs to avoid, and the rules of thumb that experienced developers reach for.

What async does #

Adding async to a function does exactly two things:

  1. The function always returns a Promise.
  2. The function body is allowed to use await.
async function getUser() {
  return { name: 'Ada' };
}

const result = getUser();
// result is Promise<{ name: 'Ada' }>, not the object itself
result.then(user => console.log(user));

Even if you return a plain value, calling async function wraps it in a Promise. If you throw, the Promise rejects.

async function failing() {
  throw new Error('oops');
}
failing().catch(e => console.log(e.message)); // 'oops'

What await does #

await pauses the function until a Promise settles, then returns the resolved value (or throws the rejection).

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

Reads top-to-bottom like synchronous code, even though there's a network call in the middle.

Three mental model points:

  1. await only works inside async functions (or at module top-level — see below).
  2. The function suspends, but the rest of the program continues. Other code can run while await is waiting.
  3. When the Promise resolves, the function resumes from the next line.

It's not a new threading model. It's a smarter way to write "do this, then that" without .then(...) chains.

Comparing the three styles #

Three functionally identical implementations of the same logic:

// Callback hell (Lesson 5.1)
fetchUser(id, (err, user) => {
  if (err) return console.error(err);
  fetchPosts(user.id, (err, posts) => {
    if (err) return console.error(err);
    console.log(posts.length);
  });
});

// Promise chain
fetchUser(id)
  .then(user => fetchPosts(user.id))
  .then(posts => console.log(posts.length))
  .catch(err => console.error(err));

// async/await
try {
  const user = await fetchUser(id);
  const posts = await fetchPosts(user.id);
  console.log(posts.length);
} catch (err) {
  console.error(err);
}

The async/await version reads sequentially. Errors flow through try/catch like sync code. No callback nesting, no .then chain to untangle.

Error handling: try/catch #

A rejected Promise becomes a thrown exception inside an async function:

async function safeGet(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
    return await res.json();
  } catch (err) {
    console.error('Failed:', err);
    return null;
  }
}

The try block can wrap multiple awaits, each of which can throw. One catch handles all of them — same as synchronous code.

Re-throw to propagate #

Don't swallow errors silently. Either handle them, or re-throw so callers can:

async function process() {
  try {
    await loadConfig();
  } catch (err) {
    if (err.code === 'NOT_FOUND') return DEFAULT_CONFIG;
    throw err; // unexpected — let it propagate
  }
}

The default behavior is correct #

If an async function throws and no caller catches it, you get a runtime UnhandledPromiseRejection. In Node 15+ this is fatal by default (process exits). In the browser, it logs to console. Either way, you'll know — silent failures are no longer possible without explicit .catch.

Sequential vs parallel #

The single most-misused pattern in async/await: doing things sequentially that could be parallel.

// SEQUENTIAL — each await blocks the next
async function loadEverything() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const comments = await fetchComments();
  return { user, posts, comments };
}
// Total time: user-time + posts-time + comments-time

If these three calls don't depend on each other, run them in parallel:

// PARALLEL — all three fire simultaneously
async function loadEverything() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ]);
  return { user, posts, comments };
}
// Total time: max(user-time, posts-time, comments-time)

Promise.all([p1, p2, p3]) returns a single Promise that resolves with an array of results — or rejects on the first failure.

When you don't want to fail fast #

Promise.allSettled waits for every promise regardless of success:

const results = await Promise.allSettled([
  fetchUser(),
  fetchPosts(),
  fetchComments(),
]);
// results: [
//   { status: 'fulfilled', value: ... },
//   { status: 'rejected', reason: ... },
//   { status: 'fulfilled', value: ... },
// ]

Use when partial success is meaningful — "load everything you can, report what failed."

Promise.race and Promise.any #

await Promise.race([fetchSlow(), timeout(5000)]); // first to settle (resolve OR reject)
await Promise.any([primary(), backup()]);          // first to RESOLVE; rejects only if all fail

Use race for timeouts (one of them is the timeout promise that rejects), any for redundancy.

Awaiting in loops #

The sequential trap shows up here, too:

// SEQUENTIAL — one at a time, slow
async function processAll(ids) {
  const results = [];
  for (const id of ids) {
    const data = await fetchById(id);
    results.push(data);
  }
  return results;
}

If the operations are independent, parallelize:

// PARALLEL — all fire at once
async function processAll(ids) {
  return Promise.all(ids.map(id => fetchById(id)));
}

When you must process sequentially (each step depends on the previous, or you're rate-limiting), the for...of + await form is correct.

forEach doesn't await #

A common bug:

async function broken(ids) {
  ids.forEach(async (id) => {
    await fetchById(id);   // each iteration's promise is dropped
  });
  console.log('done');     // logs immediately — the awaits aren't actually awaited
}

forEach doesn't understand Promises. Use for...of or Promise.all(...map(...)).

Top-level await #

In ES modules (Lesson 6.1), you can await at the module's top level:

// module.js
const config = await loadConfig();
export const api = createClient(config);

No wrapping async function main() {} needed. The module's evaluation waits for the await before its exports are available to importers.

Useful for one-shot setup. Doesn't work in CommonJS or inside non-async functions.

Common patterns #

Retry with backoff #

async function withRetry(fn, retries = 3) {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === retries - 1) throw err;
      await new Promise(r => setTimeout(r, 2 ** attempt * 500));
    }
  }
}

Timeout via Promise.race #

function timeout(ms) {
  return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}

await Promise.race([fetch(url), timeout(5000)]);

Or the modern way:

await fetch(url, { signal: AbortSignal.timeout(5000) });

Awaiting a delay #

await new Promise(r => setTimeout(r, 1000));  // sleep 1 sec

Often wrapped:

const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(1000);

What await does NOT do #

  • It does not block the thread. Other code can run while waiting. JavaScript is still single-threaded.
  • It does not magically make sync APIs async. await only meaningfully waits on Promises. await 42 is just 42 (well, on the next microtask, but the effect is identical).
  • It does not change the semantics of the wrapped Promise. A Promise that resolves in 100 ms is still done in 100 ms.

A summary #

  • async function always returns a Promise. Even when you return a plain value.
  • await pauses the function until the Promise settles. Other code runs in the meantime.
  • Errors propagate via try/catch. Rejections become thrown exceptions.
  • Promise.all for parallel independent work. await in a for...of loop only when each step depends on the previous.
  • Top-level await works in ES modules.
  • forEach doesn't await. Use for...of or map+Promise.all.

Master these and you'll write async JavaScript that reads as cleanly as the synchronous version.

What's next #

That completes Module 5. The remaining Module 6 lesson (ES modules) ties everything together: top-level await, native imports, and the modern module system every modern runtime uses.

Try it yourself #

The sequential-vs-parallel timing difference is the most useful one to feel:

YouPredict total time for each:
const wait = (ms) => new Promise(r => setTimeout(r, ms));

// A
const a = await wait(1000);
const b = await wait(1000);

// B
await Promise.all([wait(1000), wait(1000)]);
Claude · used js_sandboxVersion A: ~2000 ms. The two awaits are sequential — wait 1 second, then wait another second.
Version B: ~1000 ms. Both wait calls start at the same instant; Promise.all resolves when both finish.

That’s why parallelizing independent awaits matters in real APIs — the same logic can take half the wall-clock time.

One simple change — wrapping with Promise.all instead of stacking awaits — can cut your latency in half wherever the work is independent. Get this instinct right and async code stops being mysterious.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *