Skip to main content

Command Palette

Search for a command to run...

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Published
6 min read
Async/Await in JavaScript: Writing Cleaner Asynchronous Code
S
I write code , that run in the browser and someone else's machine. And sometimes I also write articles

As JavaScript applications evolved, handling asynchronous operations became increasingly complex. First came callbacks. Then Promises improved structure and readability. But even Promise chains can become difficult to follow when multiple asynchronous steps depend on each other.

`Async/Await was introduced to solve this readability and maintainability problem.

It is important to understand one key idea from the beginning:

Async/await is built on top of Promises.
It does not replace them.
It makes them easier to read and write.

Let us understand this carefully and step by step.


Why Async/Await Was Introduced

JavaScript is single-threaded. That means it can execute only one operation at a time. However, many operations take time:

  • Fetching data from an API

  • Reading files

  • Waiting for a timer

  • Querying a database

Instead of blocking the program, JavaScript uses asynchronous behavior.

Originally, this was handled with callbacks:

setTimeout(() => {
  console.log("Data loaded");
}, 1000);

When multiple async steps were required, callbacks became nested and messy.

Promises improved this by allowing chaining:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data received");
    }, 1000);
  });
}

fetchData()
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.log(error);
  });

This is cleaner than callbacks, but chaining multiple operations can still reduce clarity.


Deep Breakdown of the Promise Example

Let us carefully examine this function:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data received");
    }, 1000);
  });
}

Step by step what happens:

  1. fetchData() returns a Promise object.

  2. The Promise waits 1 second using setTimeout.

  3. After 1 second, it calls resolve("Data received").

  4. The Promise becomes fulfilled.

  5. The .then() block receives the resolved value.

When we run:

fetchData().then(result => {
  console.log(result);
});

Execution flow:

  • JavaScript calls fetchData().

  • A Promise is returned immediately.

  • After 1 second, the Promise resolves.

  • The .then() callback runs with the result.

Now imagine doing this multiple times:

fetchData()
  .then(result1 => {
    console.log(result1);
    return fetchData();
  })
  .then(result2 => {
    console.log(result2);
  });

The flow is:

  • Wait for first result

  • Log it

  • Return another Promise

  • Wait again

  • Log second result

This works, but the logic begins to stretch horizontally and mentally.


Introducing Async Functions

Now let us rewrite the same logic using async/await.

async function getData() {
  const result = await fetchData();
  console.log(result);
}

getData();

Here is what is happening internally:

  • async makes getData() automatically return a Promise.

  • Inside the function, await fetchData() pauses execution.

  • When the Promise resolves, its value is assigned to result.

  • Then the next line runs.

Important clarification:

await does not block the entire JavaScript program.
It only pauses execution inside that specific async function.

Other code outside continues running.


Understanding How Async Functions Return Promises

Consider this:

async function example() {
  return "Hello";
}

Even though we return a string, JavaScript wraps it automatically:

Internally it behaves like:

function example() {
  return Promise.resolve("Hello");
}

So when you call:

example().then(result => {
  console.log(result);
});

It works exactly like a normal Promise.


Deep Example: Simulating an API Call

Let us simulate getting user data.

function getUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: "Alice", age: 25 });
    }, 1000);
  });
}

Using Promises

getUser()
  .then(user => {
    console.log(user.name);
  })
  .catch(error => {
    console.log(error);
  });

Execution breakdown:

  • getUser() returns a Promise.

  • After 1 second, it resolves with { name: "Alice", age: 25 }.

  • The .then() callback receives that object.

  • user.name prints "Alice".


Using Async/Await

async function displayUser() {
  const user = await getUser();
  console.log(user.name);
}

displayUser();

Execution breakdown:

  1. displayUser() is called.

  2. Because it is async, it returns a Promise.

  3. Inside the function, execution reaches await getUser().

  4. The function pauses at that line.

  5. After 1 second, getUser() resolves.

  6. The resolved object is assigned to user.

  7. Execution continues to console.log(user.name).

Notice how this reads sequentially:

  • Get user

  • Then log name

The flow feels natural.


Handling Errors with Async/Await

In Promise chains, we use .catch().

With async/await, we use try...catch.

Promise Error Handling

fetchData()
  .then(result => console.log(result))
  .catch(error => console.log("Error:", error));

Async/Await Error Handling

async function getData() {
  try {
    const result = await fetchData();
    console.log(result);
  } catch (error) {
    console.log("Error:", error);
  }
}

getData();

What happens if fetchData() rejects?

  • The Promise rejects.

  • await throws an error.

  • The error is caught inside the catch block.

This resembles traditional synchronous error handling:

try {
  // risky code
} catch (error) {
  // handle error
}

This similarity improves readability significantly.


Multiple Sequential Async Operations

Let us see a realistic scenario.

async function processData() {
  const result1 = await fetchData();
  console.log("First:", result1);

  const result2 = await fetchData();
  console.log("Second:", result2);
}

processData();

Step-by-step execution:

  1. Wait for first fetchData() to finish.

  2. Log the result.

  3. Wait for second fetchData() to finish.

  4. Log the result.

This reads exactly like synchronous logic.

Compare that to chained Promises, which grow vertically and require mental tracking of return values.


Async/Await Is Syntactic Sugar

Very important concept:

This async/await code:

const result = await fetchData();

Is internally equivalent to:

fetchData().then(result => {
  // continue execution
});

Async/await simply hides the .then() chaining and presents it in a more readable way.

The underlying mechanism is still Promises.


Real-World Example with Fetch API

async function fetchUser() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/users/1");

    const data = await response.json();

    console.log(data.name);
  } catch (error) {
    console.log("Error fetching data:", error);
  }
}

fetchUser();

Detailed breakdown:

  1. fetch() returns a Promise.

  2. await fetch(...) waits for the HTTP response.

  3. response.json() also returns a Promise.

  4. await response.json() waits for JSON parsing.

  5. Once resolved, data.name is printed.

  6. Any failure during this chain jumps directly to catch.

This flow is clear and sequential.


Comparison Summary

Feature Promises Async/Await
Syntax .then() chains async and await
Readability Can become nested Looks synchronous
Error Handling .catch() try...catch
Underlying System Native Promise Built on Promises

Summary

Async/await was introduced to improve the readability of asynchronous code.

It:

  • Makes asynchronous logic look synchronous

  • Simplifies chaining multiple async steps

  • Centralizes error handling

  • Reduces nested .then() calls

If callbacks made async possible, and Promises made it structured, then async/await made it readable.

Mastering async/await is essential for modern JavaScript development, especially when working with APIs, databases, and external services.