Async/Await Explained: Why We Wait (Again and Again)
Waffeu Rayn

Waffeu Rayn @it-wibrc

About: Software developer, Junior Project manager and DevOps enthusiast

Location:
Cameroon, Douala
Joined:
Nov 17, 2024

Async/Await Explained: Why We Wait (Again and Again)

Publish Date: Jun 30
0 0

Have you ever wondered why, when you call a function that fetches data, you sometimes see await in front of it, even if you know there's already an await inside that function? It can feel a bit like waiting for someone to finish making coffee, only to realize you then have to wait again for them to pour it into your cup!

Let's clear up this common confusion and make async and await super easy to understand.


The Big Idea: Waiting in Code

Imagine you're building a house.

  • Synchronous Code: This is like building the house step-by-step. You can't put on the roof until the walls are completely finished. If the wall-building takes an hour, your roof-putting-on task has to sit there for an hour, doing nothing. In programming, if one part of your code takes a long time (like fetching data from the internet), it would make your entire program freeze! That's bad for user experience.

  • Asynchronous Code: This is like delegating tasks. You tell one team, "Go build the walls! I'll come back for the roof once you tell me you're done." While they're building the walls, you can go prepare the roof tiles or paint the windows. Your program doesn't freeze; it does other useful things.

Fetching data from an API (a server on the internet) is the classic example of an asynchronous task. It takes time, and you don't want your app to freeze while it's waiting for a response.


Promises: The "I'll Be Done Later" Note

Before async and await, we used Promises. A Promise is like an "I'll be done later" note. When you start an asynchronous task, you immediately get a Promise back. This Promise tells you:

  • "I'm pending." (I'm still working.)

  • "I'm fulfilled." (I finished successfully, and here's the result!)

  • "I'm rejected." (Something went wrong, and here's the error!)

So, when you call a function that performs an async task, it doesn't immediately give you the final data. It gives you a Promise that will eventually contain the data (or an error).


async: Making Your Function Promise-Aware

The async keyword is very simple:

  1. You put async in front of a function declaration: async function fetchData() { ... }

  2. This tells JavaScript: "This function will perform asynchronous operations."

  3. Crucially: It also gives you the ability to use the await keyword inside this function.

  4. Most importantly: An async function always, always, always returns a Promise. No matter what you return from an async function, JavaScript will automatically wrap that return value in a Promise. If you throw an error inside an async function, JavaScript wraps that error in a Promise that gets rejected.

    Think of it like a chef who promises you a dish. Even if they just stir a pot (a very quick step), they still hand you a "dish-in-progress" ticket when you ask for it.


await: Patiently Waiting for the Promise

The await keyword can only be used inside an async function.

  1. You put await in front of an expression that returns a Promise: const data = await somePromise;

  2. It tells JavaScript: "Pause the execution of this async function right here."

  3. "Wait until the Promise I'm waiting on (the one right after await) either resolves (finishes successfully) or rejects (fails)."

  4. If the Promise resolves: await "unwraps" the Promise and gives you the actual value it resolved with. Your code then continues with that value.

  5. If the Promise rejects: await effectively "throws" that rejection as a regular JavaScript error. This means you can use a normal try...catch block around your await calls to handle errors gracefully.

    This is you, the customer, patiently waiting at the counter with your "dish-in-progress" ticket until the chef actually hands you the finished dish.


The Big Question: Why await Again? (The Double Wait)

This is the "aha!" moment for many. Let's use our chef analogy:

You have a "Dish Ordering" function (orderMeal) and a "Chef's Cooking" function (cookDish).

// The Chef's Kitchen (cookDish function)
async function cookDish(): Promise<string> {
  console.log("Chef: Starting to cook...");
  // Chef awaits ingredients delivery
  const ingredients = await getIngredientsDelivery(); // AWAIT 1 (internal to chef)
  console.log("Chef: Got ingredients!");
  // Chef awaits cooking process
  const cookedFood = await performCooking(ingredients); // AWAIT 2 (internal to chef)
  console.log("Chef: Dish is ready!");
  return "Delicious " + cookedFood; // Chef returns the finished dish.
  // BUT since cookDish is 'async', it *actually* returns Promise<"Delicious ...">
}

// Your Dining Experience (orderMeal function)
async function orderMeal() {
  console.log("Customer: Ordering meal...");
  // You call the chef. What does cookDish() immediately give you?
  // It gives you a Promise (a "Dish Promise Ticket")!
  const mealPromise = cookDish();
  console.log("Customer: Got a promise ticket:", mealPromise); // Prints Promise { <pending> }

  // To get the *actual finished dish*, you need to await that promise ticket.
  const actualDish = await mealPromise; // AWAIT 3 (external to chef, you await their final promise)
  console.log("Customer: Received the actual dish:", actualDish);
}

// Kick it off
orderMeal();
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Internal await (AWAIT 1 & 2): The awaits inside cookDish are for the chef's own internal steps (getting ingredients, cooking). The chef needs to wait for these sub-tasks to finish before they can complete their overall job.

  • External await (AWAIT 3): The await outside cookDish (in orderMeal) is because cookDish itself is an async function, and therefore, it returns a Promise. To get the final string value ("Delicious cooked food") out of that Promise, you, the caller, must await it.

The async keyword effectively wraps your function's result in a Promise. The await keyword then unwraps a Promise to give you its value. This wrapping and unwrapping is why you see await at different levels: one for the internal workings, and one for the function's overall promised result.


Real-World Example: Fetching User Data

Let's see this in a common scenario: fetching user data from an API.

// 1. The Service Layer (internal 'await's)
// This function fetches data from a server and returns a Promise of UserData.
type UserData = { id: string; name: string; email: string };

async function fetchUserFromAPI(userId: string): Promise<UserData> {
  console.log(`[Service] Fetching data for user ${userId}...`);
  try {
    // Await the network request itself
    const response = await fetch(`https://api.example.com/users/${userId}`); // AWAIT 1 (network call)

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    // Await the parsing of the JSON response body
    const data: UserData = await response.json(); // AWAIT 2 (JSON parsing)
    console.log(`[Service] Data fetched for user ${userId}.`);
    return data; // This will be wrapped in a Promise by 'async'
  } catch (error: any) {
    console.error(`[Service Error] Failed to fetch user ${userId}:`, error.message);
    throw new Error(`Could not get user data for ${userId}.`); // Re-throwing to caller
  }
}

// 2. The Application Layer (external 'await's)
// This function uses the service to fetch data and display it.
async function displayUserProfile(userId: string) {
  console.log(`[App] Preparing to display profile for ${userId}...`);
  try {
    // Await the Promise returned by fetchUserFromAPI
    const userProfile = await fetchUserFromAPI(userId); // AWAIT 3 (awaiting the service's promise)

    console.log(`[App] Displaying profile for ${userProfile.name}:`);
    console.log(`ID: ${userProfile.id}, Email: ${userProfile.email}`);
  } catch (error: any) {
    console.error(`[App Error] Failed to display profile:`, error.message);
    // Show an error message to the user in the UI
  }
  console.log(`[App] Profile display process complete.`);
}

// --- Let's run it (in a real browser/Node.js environment) ---
// Note: These console logs might not appear in this specific chat window
// but would in your browser's console or Node.js terminal.
console.log("Starting application...");
// This line itself does NOT wait for displayUserProfile to finish.
// It just calls the async function, which returns a Promise.
displayUserProfile("1");
displayUserProfile("invalid-id-that-will-fail"); // Example of an ID that would cause an error
console.log("Application started. Waiting for profiles to load...");

// A simple way to simulate API response for this article without actual network calls:
// In a real scenario, you'd replace 'fetch' with a mock during testing.
// For this example to "run" here, assume 'fetch' resolves/rejects as shown in the console logs.
Enter fullscreen mode Exit fullscreen mode

In the example above, fetchUserFromAPI uses await internally to handle its own async steps. But displayUserProfile must also use await when calling fetchUserFromAPI because fetchUserFromAPI is an async function and thus always returns a Promise.


Conclusion

The "repetitive" await is actually a fundamental aspect of how async/await works to make asynchronous code appear sequential.

  • async functions return Promises.

  • await unwraps Promises.

By understanding this wrapping and unwrapping, you can confidently use async/await to write clean, readable, and robust asynchronous code in your JavaScript and TypeScript applications!

This article is just one small piece of my journey in building robust and maintainable code. If you're passionate about clean architecture, functional programming, or practical utilities, I invite you to explore my Coding Remarks repository. Let's learn and build better software together!

Comments 0 total

    Add comment