Part 3: Async/Await

Part 3: Async/Await

March 2026·Technical

In part one we covered the event loop — the engine that makes async JavaScript possible. In part two we saw how Promises replaced callback hell with flat, chainable code. In this article, we'll cover the modern way: async/await.

Think of Pizza

If you aren't reading this from a Pizza Shop, think of one. You walk into the restaurant on a Friday afternoon. It's Friday so it's packed. You step up to the counter and place your order — a large pepperoni. The cashier smiles — you think she's trying to be your friend but that's just part of her job, asks you to pay first and hands you a little receipt with the number 47 on it, and says "I'll call you when it's ready."

There's nothing much on your part now, you can't stand at the counter — or shouldn't. You don't block the line. You go find a seat. You scroll your phone. You talk to the person sitting next to you about the government. All this while, the guys in the kitchen are doing their thing — stretching dough, layering toppings — completely separate from what you're doing.

Then after a few minutes the speaker: "Number 47!" You get up, walk to the counter, pick up your pizza, and carry on.

That is async/await.

You (the JavaScript thread) placed a request (called an async function). Instead of freezing in place waiting for it, you were handed a ticket (a Promise) and sent to sit down. The kitchen (browser/Node.js APIs) handled the slow work independently. When it was done, your number was called (the Promise resolved) and you picked up the result (await gave you the value). The counter never blocked. Other customers kept ordering. The restaurant kept running.

What async/await Actually Is

Of first importance:

async/await is not a new async mechanism. It is syntax built on top of Promises.

Under the hood, it's Promises. The only difference is how the code looks. This matters because any confusion you have about Promises directly carries into async/await.

Two keywords:

async — marks a function as asynchronous. An async function always returns a Promise, even if you return a plain value.

await — pauses execution of the current function until the Promise resolves, then gives you the resolved value. Can only be used inside an async function.

// Promise version
function loadUser() {
  return fetchUser(1)
    .then(user => fetchPosts(user.id))
    .then(posts => posts);
}

// async/await version — same behaviour but different look
async function loadUser() {
  const user  = await fetchUser(1);
  const posts = await fetchPosts(user.id);
  return posts;
}

Both do exactly the same thing.

await Pauses the Function, Not the Thread

When we write await somePromise, JavaScript pauses that function and returns control to the event loop. The main thread is completely free and so other code can run, and the browser can respond to clicks and everything else keeps working.

async function loadDashboard() {
  console.log('Starting fetch...');

  const user = await fetchUser(1); // function pauses here
  // everything else in the app keeps running during this pause

  console.log('Got user:', user); // resumes here when Promise resolves
}

loadDashboard();
console.log('This runs immediately'); // runs while loadDashboard is paused

Output:

Starting fetch...
This runs immediately
Got user: { id: 1, name: 'Steve' }  ← appears 1 second later

Error Handling with try/catch

With .then() chaining we used .catch(). With async/await you use the standard try/catch — and it works correctly here because the await keyword unwraps the Promise result back into synchronous-style code.

async function loadDashboard(userId) {
  try {
    const user  = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    renderUser(user);
    renderPosts(posts);

  } catch (error) {
    // any rejected await in the try block lands here
    showError(error.message);

  } finally {
    hideLoading(); // always runs whether success or failure
  }
}

We have three blocks, three jobs:

  • try — the happy path. What you want to happen.
  • catch — anything fails anywhere in try, execution jumps here. One handler for everything; bad network, 404 response, a bug in renderUser.
  • finally — runs regardless of outcome. This is critical for cleanup. Without it, if an error occurs, hideLoading() never runs and the spinner spins forever.

In the Crispy Async demo, when the Trigger Error button is clicked, it tries to fetch a user that doesn't exist. The fetch "succeeds" (network-wise), but response.ok is false, so we throw manually. The catch block shows the error message, finally hides the spinner and the entire flow works correctly because of this structure.

Sequential vs Parallel — The Performance Trap

You could be writing slow sequential code without realising it.

// Sequential — each waits for the one before it
async function loadDashboard(userId) {
  const user     = await fetchUser(userId);    // 1 second
  const posts    = await fetchPosts(userId);   // 1 more second
  const settings = await fetchSettings(userId); // 1 more second
  // Total: ~3 seconds
}

Each await pauses the function: fetchPosts doesn't start until fetchUser finishes. fetchSettings doesn't start until fetchPosts finishes.

However, posts and settings don't depend on user, they just need userId, which we already have so we're actually waiting for no reason.

// Parallel — all start simultaneously
async function loadDashboard(userId) {
  const user = await fetchUser(userId); // still need this first

  // posts and settings both need userId, don't depend on each other
  const [posts, settings] = await Promise.all([
    fetchPosts(userId),
    fetchSettings(userId)
  ]);
  // Total: ~2 seconds (1s for user + 1s for both others simultaneously)
}

Promise.all starts both requests at the same time. Total time is the slowest of the two, not the sum. On a dashboard that makes five or six API calls, the difference of course becomes very noticeable. The rule is to use sequential await when each step depends on the result of the previous step and to use Promise.all when operations are independent.

Common Mistakes From my Experience

1. Forgetting await

async function save(data) {
  database.save(data);       // returns a Promise nobody is waiting for
  console.log('Saved!');     // runs before the save completes
}

There are no errors or warnings, the function runs, console.log fires, and the DB operation finishes sometime later — or maybe fails silently. Always await async operations.

2. await inside .forEach()

// forEach doesn't understand async — the awaits are effectively ignored
const ids = [1, 2, 3];
ids.forEach(async (id) => {
  const user = await fetchUser(id);
  console.log(user);
});
console.log('done'); // prints before any user is fetched

forEach fires each callback but doesn't wait for the returned Promises. Use for...of for sequential, or .map() with Promise.all for parallel:

// Sequential
for (const id of ids) {
  const user = await fetchUser(id);
  console.log(user);
}

// Parallel
const users = await Promise.all(ids.map(id => fetchUser(id)));

3. async in React's useEffect

// useEffect cannot receive an async function directly
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// define async function inside, then call it
useEffect(() => {
  async function load() {
    const data = await fetchData();
    setData(data);
  }
  load();
}, []);

useEffect expects either nothing or a cleanup function as its return value. An async function always returns a Promise — useEffect doesn't know what to do with it so always define the async function inside and call it separately.

Putting It All Together

This is the core of the Crispy Async dashboard, with every concept I've covered across all three articles present in one function:

async function loadDashboard(userId) {
  clearUI();
  showLoading();                           // show the async gap

  try {
    const [user, posts] = await Promise.all([  // parallel fetching
      fetchUser(userId),                       // real fetch() Promises
      fetchPosts(userId)
    ]);

    renderUser(user);                      // DOM manipulation
    renderPosts(posts);

  } catch (error) {                        // one handler for all failures
    showError(error.message);

  } finally {
    hideLoading();                         // always runs 
  }
}

Line by line breakdown:

  • showLoading() — makes the spinner visible, starts the async gap
  • Promise.all — two network requests, running in parallel
  • await — pauses this function, thread stays free, spinner keeps spinning
  • catch — handles any failure from either fetch or either render call
  • finally — spinner hides no matter what happened

The Full Picture

Across these three articles, we've built from the ground up:

JavaScript is single-threaded
        ↓
Blocking the thread freezes everything
        ↓
The event loop handles async without blocking
        ↓
Callbacks — the original pattern, but nests into chaotic pyramid of doom
        ↓
Promises — flat chaining, one catch for all errors
        ↓
async/await — Promises but reads like synchronous code
        ↓
try/catch/finally — familiar error handling that actually works
        ↓
Promise.all — parallel execution for independent operations