Skip to main content

Command Palette

Search for a command to run...

The Illusion of Synchrony: Mastering Async/Await in JavaScript

Promises fixed the callback pyramid of doom, but they left us chaining .then(). Here is how async/await makes asynchronous JavaScript readable again.

Published
5 min read
H
CS Graduate | Technical Writing | Software development | 20K+ impressions

If you have been following the evolution of JavaScript, you know the story. First, we relied on callbacks to handle asynchronous tasks, which led to deeply nested, unreadable code. To fix that, JavaScript introduced Promises, which allowed us to flatten our code using .then() and .catch() chains.

Promises were a massive leap forward. But they still had a readability problem.

Even with Promises, you are still passing callback functions into .then(). You are still artificially breaking your logic into blocks. And if you need to share a variable between step 1 and step 3 of a Promise chain, you end up doing awkward variable scoping gymnastics.

What we really wanted was a way to write asynchronous code that looks and feels exactly like standard, top-to-bottom synchronous code.

In ES2017, JavaScript gave us exactly that with async and await.


The Big Secret: It’s Just Syntactic Sugar

Before we look at the syntax, we need to establish one hard truth: async/await does not replace Promises.

It is entirely built on top of them. It is what we call "syntactic sugar"—a cleaner, sweeter syntax wrapped around the exact same underlying mechanics. When you write async/await, JavaScript is quietly creating Promises and attaching .then() handlers under the hood. The event loop operates exactly the same way.

Understanding this makes the behavior of async/await perfectly predictable. Let's break down the two keywords.

1. The async Keyword: The Promise Wrapper

The async keyword is placed in front of a function declaration. Doing this changes the function in one very specific way: it guarantees that the function will return a Promise.

Even if you explicitly return a simple primitive value, JavaScript will wrap it in a resolved Promise.

// This looks like it returns a number...
async function getNumber() {
    return 42;
}

// ...but it actually returns a Promise!
const result = getNumber();
console.log(result); // Output: Promise {<fulfilled>: 42}

Because getNumber() returns a Promise, you could chain .then() onto it. But that defeats the purpose. We don't use async just to return Promises; we use it to unlock the use of the await keyword inside that function.

2. The await Keyword: The Magic Pause Button

The await keyword can only be used inside an async function. When you place await in front of a Promise, you are telling JavaScript: "Pause the execution of this specific function until this Promise settles (either fulfills or rejects)."

This is where the magic happens. Let’s look at a side-by-side comparison of fetching a user from an API.

The Promise Way:

function fetchUser() {
    console.log("Fetching...");
    
    fetch('https://api.example.com/user/1')
        .then(response => response.json())
        .then(user => {
            console.log("User:", user.name);
        });
}

The Async/Await Way:

async function fetchUser() {
    console.log("Fetching...");
    
    // Execution pauses here until the network request finishes
    const response = await fetch('https://api.example.com/user/1');
    
    // Execution pauses here until the JSON is parsed
    const user = await response.json();
    
    console.log("User:", user.name);
}

Notice what happened? The .then() callbacks are gone. We simply assign the resolved value of the Promise directly to a variable (const response = ...).

Mental Diagram: Execution Flow

  1. JavaScript enters the fetchUser function.

  2. It logs "Fetching...".

  3. It hits await fetch(...). It fires off the network request and immediately exits the function, handing control back to the main thread so the browser doesn't freeze.

  4. When the network request finishes, JavaScript jumps back into the function exactly where it left off, unwraps the Promise, assigns the data to response, and moves to the next line.

It gives you the exact non-blocking performance of Promises, with the clean, procedural readability of synchronous code.


Unifying Error Handling with try/catch

One of the most frustrating parts of mixing synchronous and asynchronous code using Promises was error handling. Synchronous code used standard try/catch blocks, while Promises required .catch() chains.

Because await pauses execution and unwraps Promises, it allows us to handle asynchronous errors using standard, synchronous try/catch blocks. If an awaited Promise rejects, it throws an exception just like a standard JavaScript error.

async function getDashboardData() {
    try {
        const response = await fetch('https://api.example.com/data');
        
        // If the fetch fails (e.g., network down), it jumps to the catch block
        if (!response.ok) {
            throw new Error(`HTTP error: ${response.status}`);
        }
        
        const data = await response.json();
        return data;

    } catch (error) {
        // This single block catches network errors, parsing errors, 
        // and standard JS errors.
        console.error("Failed to load dashboard:", error.message);
        
        // We can render a fallback UI here
    }
}

This is the ultimate benefit of async/await. We are no longer managing two different paradigms of logic in our heads. We write our loops, our conditionals, and our error handling the exact same way, whether the data is coming from local memory or a server halfway across the world.

The Takeaway

When you write modern JavaScript, async/await should be your default tool for handling asynchronous actions.

  • Use Promises when you need to fire off multiple tasks at exactly the same time (like using Promise.all()).

  • Use async/await for everything else.

It strips away the boilerplate of .then() callbacks, removes the awkward variable scoping issues, and lets you read your application's logic exactly how the computer intends to execute it: step by step.