JavaScript

Mastering Async/Await in JavaScript

Learn how to handle asynchronous operations elegantly with async/await syntax.

Plake
Mar 5, 20267 min read1,211 words
Mastering Async/Await in JavaScript

Asynchronous code is everywhere in JavaScript — every network request, file read, timer, and database query returns the result later, not immediately. How you express “later” has changed dramatically over the years, and the way you write it today affects how readable, debuggable, and correct your code is. This guide takes you from the original problem all the way to the patterns you will reach for daily.

The Problem: Everything Takes Time

JavaScript runs on a single thread. If a network request blocked that thread, the entire page would freeze until the response arrived. Instead, JavaScript delegates waiting to the event loop: register what to do when the result is ready, then move on.

The first solution was callbacks.

Callbacks: The Original Approach

fs.readFile("config.json", "utf8", (err, data) => {
  if (err) {
    console.error("Read failed:", err);
    return;
  }
  JSON.parse(data); // use the data
});

This works, but composing async operations creates the famous pyramid of doom:

getUserById(42, (err, user) => {
  if (err) return handleError(err);
  getOrdersForUser(user.id, (err, orders) => {
    if (err) return handleError(err);
    getProductDetails(orders[0].productId, (err, product) => {
      if (err) return handleError(err);
      // ... buried six levels deep
    });
  });
});

Error handling is duplicated at every level. The flow is hard to follow. Promises were designed to fix this.

Promises

A Promise is an object that represents a value that will be available in the future. It has three states: pending, fulfilled, or rejected.

function fetchUser(id) {
  return fetch(`/api/users/${id}`).then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  });
}

fetchUser(42)
  .then((user) => console.log(user.name))
  .catch((err) => console.error("Failed:", err))
  .finally(() => hideSpinner());

.then() chains are flatter than callbacks, but they still require careful management of the chain and still diverge from synchronous mental models. async/await is syntactic sugar over Promises that makes async code read like synchronous code.

Async Functions

An async function always returns a Promise. Inside it, await pauses execution until the awaited Promise settles:

async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json(); // auto-wrapped in a Promise
}

Calling it looks like any other function:

const user = await fetchUser(42);
console.log(user.name);

The runtime saves the point of suspension and resumes when the fetch completes. No callbacks, no .then() chains — the engine handles it.

Error Handling

Use try/catch exactly as you would for synchronous errors:

async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const [orders, notifications] = await Promise.all([
      fetchOrders(user.id),
      fetchNotifications(user.id),
    ]);
    return { user, orders, notifications };
  } catch (err) {
    if (err.status === 404) {
      return null; // user not found — not an error worth re-throwing
    }
    throw err; // unexpected error — let the caller decide
  }
}

One catch block handles all failures in the try block, regardless of which await threw. Compare this to duplicating if (err) return handleError(err) in every callback.

Forgetting to catch

A common mistake is creating a floating Promise that rejects silently:

// Bad — rejection is unhandled, will crash in Node.js
saveUser(data);

// Good — either await and catch, or explicitly handle
try {
  await saveUser(data);
} catch (err) {
  logError(err);
}

In Node.js, an unhandled rejection terminates the process since v15. In browsers, it fires an unhandledrejection event. Neither is graceful.

Sequential vs Parallel Execution

This is the most common performance trap with async/await:

// Sequential — waits 200ms then 150ms = 350ms total
const user = await fetchUser(id);
const orders = await fetchOrders(id);

If the two fetches are independent, run them in parallel:

// Parallel — both fire at once, wait for the slower = ~200ms
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);

Promise.all takes an array of Promises and resolves when all of them resolve. If any rejects, the whole thing rejects immediately (fail-fast).

For partial failures — where some failures are acceptable — use Promise.allSettled:

const results = await Promise.allSettled([
  fetchUser(id),
  fetchOrders(id),
  fetchRecommendations(id), // can fail gracefully
]);

const [userResult, ordersResult, recsResult] = results;

if (userResult.status === "fulfilled") {
  render(userResult.value);
}
if (recsResult.status === "rejected") {
  showFallbackRecommendations();
}

Promise Combinators

Method Resolves when Rejects when
Promise.all(arr) All resolve Any rejects
Promise.allSettled(arr) All settle (never rejects) —
Promise.any(arr) First resolves All reject
Promise.race(arr) First settles First settles with rejection

Promise.race is useful for timeouts:

async function fetchWithTimeout(url, ms = 5000) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Request timed out")), ms),
  );
  return Promise.race([fetch(url), timeout]);
}

Async Iteration

For processing streams or paginated APIs, for await...of handles async iterables cleanly:

async function* paginatedOrders(userId) {
  let cursor = null;
  do {
    const page = await fetchOrderPage(userId, cursor);
    yield* page.orders;
    cursor = page.nextCursor;
  } while (cursor);
}

for await (const order of paginatedOrders(42)) {
  processOrder(order);
}

The generator yields one order at a time; the loop awaits the next value naturally.

Common Pitfalls

Awaiting inside forEach — forEach doesn’t understand Promises. The loop exits before the async operations complete:

// Bug — all writes fire and the function returns before they finish
users.forEach(async (user) => {
  await saveUser(user); // this await only pauses the callback, not the loop
});

// Fix — use for...of or Promise.all
for (const user of users) {
  await saveUser(user);
}

// Or parallel:
await Promise.all(users.map((user) => saveUser(user)));

Missing await on a Promise-returning function:

// Bug — data is a Promise object, not the resolved value
const data = fetchData();
console.log(data.name); // undefined

// Fix
const data = await fetchData();
console.log(data.name);

Catching inside when you should let errors propagate:

// Usually wrong — swallows all errors silently
async function getUser(id) {
  try {
    return await fetchUser(id);
  } catch {
    return null; // caller can't distinguish "not found" from "network error"
  }
}

Only catch what you intend to handle. Let unexpected errors bubble.

JavaScript async illustration

Real-World Pattern: Data Fetching in React

function useUserData(userId: string) {
  const [state, setState] = useState<{
    user: User | null;
    loading: boolean;
    error: Error | null;
  }>({ user: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;

    async function load() {
      try {
        const user = await fetchUser(userId);
        if (!cancelled) setState({ user, loading: false, error: null });
      } catch (err) {
        if (!cancelled)
          setState({ user: null, loading: false, error: err as Error });
      }
    }

    load();
    return () => {
      cancelled = true;
    }; // prevent setState after unmount
  }, [userId]);

  return state;
}

The cancelled flag prevents a stale response from updating state after the component unmounts or the userId changes — a subtle bug that async/await makes easy to introduce and easy to fix.

Closing Thoughts

async/await doesn’t eliminate the asynchronous nature of JavaScript — it makes it expressible in terms you already understand. The rules are simple: await pauses the current function, not the thread; Promises that could run in parallel should run in parallel; and every rejected Promise should be caught somewhere. Master those three ideas and the rest is grammar.

Share:

About the author

The Plake Team

Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.

Read more

Keep reading

Related Articles

Back to AllBack to All
React Hooks: A Deep Dive into Modern React Development

Feb 25, 2026

8 min

React Hooks: A Deep Dive into Modern React Development

Getting Started with Astro 5: A Beginner's Guide

Feb 15, 2026

6 min

Getting Started with Astro 5: A Beginner's Guide