Skip to content
C Codeloom
JavaScript

async/await in JavaScript: A Practical Guide

A practical guide to async functions and await — writing asynchronous code that reads like synchronous code, handling errors with try/catch, and running work in parallel.

·9 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How async functions and await work under the hood
  • Writing asynchronous code that reads top-to-bottom
  • Error handling with try/catch around await
  • When awaits run sequentially — and how to parallelize
  • A short fetch-and-display walkthrough
  • What top-level await lets you do (and where)

Prerequisites

async/await is the syntax that made asynchronous JavaScript look ordinary. Under the hood, it’s still promises — exactly the ones from the previous post. The keywords let you write code that reads top to bottom, with familiar try/catch for errors, while the engine handles the suspension and resumption.

If promises feel like plumbing, async/await is the faucet.

async functions

Any function can be made async by adding the keyword in front:

async function greet() {
  return "hello";
}

console.log(greet());    // Promise { 'hello' }

An async function always returns a promise. Whatever you return becomes the fulfillment value; if you throw, the promise rejects. The console.log above prints a promise, not the string.

To consume the result:

greet().then((value) => console.log(value));    // 'hello'

The same applies to arrow functions and methods:

const greet = async (name) => `hello ${name}`;

class Greeter {
  async greet(name) {
    return `hello ${name}`;
  }
}

By itself, async is just a way to make a function return a promise. The real magic is await.

await

Inside an async function, await pauses execution until the promise on its right settles:

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

async function run() {
  console.log("before");
  await delay(500);
  console.log("after 500ms");
  await delay(500);
  console.log("after another 500ms");
}

run();

await delay(500) evaluates the expression on the right (a promise), then suspends the function until that promise fulfills. The function then resumes on the next line.

If the awaited promise fulfills, await gives you its value:

async function loadUser() {
  const response = await fetch("/api/user");
  const user = await response.json();
  return user;
}

If it rejects, await throws — which means you handle errors with normal try/catch.

Error handling with try/catch

This is the big readability win. Asynchronous errors flow through the same construct as synchronous ones:

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    return user;
  } catch (err) {
    console.error("Failed to load user:", err.message);
    throw err;     // optional — re-raise so callers can handle it too
  }
}

Compare with the equivalent promise chain — both are perfectly valid, but the try/catch form scales better as logic grows. Especially when you have conditional branches, loops, or both success and failure paths that share cleanup.

A finally block does what you’d expect — runs whether the try block finished normally or threw:

async function withTimer(work) {
  const start = Date.now();
  try {
    return await work();
  } finally {
    console.log(`took ${Date.now() - start}ms`);
  }
}

Sequential vs parallel awaits

This is the single biggest performance pitfall with async/await. Each await is a barrier — the function won’t proceed until the awaited promise settles. So this code runs the three requests one after the other:

async function loadAllSequential() {
  const user = await fetch("/api/user");
  const posts = await fetch("/api/posts");
  const comments = await fetch("/api/comments");
  return { user, posts, comments };
}

Total time: the sum of all three requests.

If the three requests don’t depend on each other, kick them all off first, then await:

async function loadAllParallel() {
  const userPromise = fetch("/api/user");
  const postsPromise = fetch("/api/posts");
  const commentsPromise = fetch("/api/comments");

  const user = await userPromise;
  const posts = await postsPromise;
  const comments = await commentsPromise;

  return { user, posts, comments };
}

Total time: the slowest of the three. The requests are issued the moment fetch is called — await later just waits for the result.

Even cleaner, with Promise.all:

async function loadAllParallel() {
  const [user, posts, comments] = await Promise.all([
    fetch("/api/user"),
    fetch("/api/posts"),
    fetch("/api/comments"),
  ]);
  return { user, posts, comments };
}

The rule of thumb: don’t await inside a loop unless you have to. Many “slow” async functions in the wild are just a for loop with an await on each iteration, when a Promise.all over a .map would run everything at once.

// Slow — N requests in series
for (const id of ids) {
  const user = await loadUser(id);
  console.log(user);
}

// Fast — N requests in parallel
const users = await Promise.all(ids.map((id) => loadUser(id)));
for (const user of users) {
  console.log(user);
}

The serial form is right when each step depends on the previous one. The parallel form is right when they’re independent.

Try it yourself. Write a pause(ms, value) helper that returns a promise resolving to value after ms ms. Then write two async functions: one that calls pause(500, "a"), pause(500, "b"), pause(500, "c") sequentially with await, and one that runs all three in parallel with Promise.all. Time each.

A worked fetch example

A realistic flow — load a user, then their posts (which depend on the user), then their comments (which depend on the posts), with all errors funnelled to one handler.

async function getUserWithActivity(userId) {
  try {
    const userResponse = await fetch(`/api/users/${userId}`);
    if (!userResponse.ok) throw new Error(`User ${userId} not found`);
    const user = await userResponse.json();

    const [postsResponse, commentsResponse] = await Promise.all([
      fetch(`/api/users/${userId}/posts`),
      fetch(`/api/users/${userId}/comments`),
    ]);

    const posts = await postsResponse.json();
    const comments = await commentsResponse.json();

    return { user, posts, comments };
  } catch (err) {
    console.error("Failed to load activity:", err.message);
    throw err;
  }
}

async function display(userId) {
  const data = await getUserWithActivity(userId);
  console.log(`${data.user.name} has ${data.posts.length} posts and ${data.comments.length} comments`);
}

display(1).catch((err) => {
  // last line of defence — rejection from display itself
  console.error("Display failed:", err);
});

Three things worth noticing:

  1. The user request must finish first (we need its id), so it’s awaited alone.
  2. Posts and comments are independent, so they go in parallel via Promise.all.
  3. Any error — bad response, JSON parse failure, network drop — is caught once at the top.

async functions and arrays

The for...of loop awaits cleanly:

async function processAll(items) {
  for (const item of items) {
    await processOne(item);    // serial — each waits for previous
  }
}

But forEach does not await. If you pass an async function to forEach, the loop returns immediately and the async work runs in the background, uncoordinated:

items.forEach(async (item) => {
  await processOne(item);     // ⚠ no overall await
});

This is a common bug. Use for...of for serial work, or Promise.all(map(...)) for parallel.

Top-level await

Inside an ECMAScript module (type: "module" in Node, <script type="module"> in browsers), await is allowed at the top level — no enclosing async function required:

// inside a module
const response = await fetch("/api/config");
const config = await response.json();
console.log(config);

This is convenient for module initialization and for scripts that do their work at startup. It is not available in classic scripts or in CommonJS files (require-based Node code). If you see SyntaxError: await is only valid in async functions, you’re in one of those environments — wrap your code in an async IIFE:

(async () => {
  const data = await loadData();
  console.log(data);
})();

What async/await is not

A few clarifications that save confusion later.

  • It is not multi-threading. JavaScript still has one thread; await just suspends the current function, freeing the thread to do other things.
  • It does not make a synchronous API asynchronous. If a function returns a plain value, await on it is harmless but pointless.
  • It does not parallelize on its own. Two await calls in sequence run in sequence. You parallelize by issuing promises first.

async and await are syntactic sugar over the promises you already know. Anything you can do with promises, you can do with async functions — usually with less code.

Try it yourself. Convert this promise chain into an async function with try/catch:

fetch("/api/posts")
  .then((r) => r.json())
  .then((posts) => posts.filter((p) => p.likes > 10))
  .then((popular) => console.log(popular.length))
  .catch((err) => console.error(err));

Then add a second fetch (/api/users) that runs in parallel with the first using Promise.all.

Common pitfalls

A short list to internalize.

Forgetting async on the function

function loadUser() {
  const user = await fetch("/api/user");    // SyntaxError
}

await is only valid inside an async function (or at top level in a module).

Forgetting await

async function loadUser() {
  const user = fetch("/api/user");      // user is a Promise, not the response
  console.log(user.status);             // undefined
}

If a value seems wrong, check whether you forgot an await.

Awaiting inside a loop when you don’t need to

Already covered above — the single biggest perf surprise.

Ignoring an async function’s promise

saveUser(user);    // returns a promise — if it rejects, nothing catches it

Either await it, attach a .catch, or let an outer caller handle it.

Recap

You now know:

  • An async function always returns a promise
  • await pauses inside an async function until the promise settles
  • A rejected awaited promise throws, so use try/catch for errors
  • Sequential awaits run one after another — slow for independent work
  • Parallelize with Promise.all over an array of promises
  • Don’t pass an async function to forEach — use for...of or Promise.all(map)
  • Top-level await works in ECMAScript modules

Next steps

You now have a complete grounding in modern JavaScript — variables, types, control flow, arrays, objects, functions, and asynchronous code. From here, the natural next stops are working with the DOM in the browser, building HTTP servers in Node, or picking up a framework. Whichever you choose, the building blocks from this series are the ones you’ll use every day.

→ Back to: What is JavaScript?

Questions or feedback? Email codeloomdevv@gmail.com.