Skip to main content

Command Palette

Search for a command to run...

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Published
โ€ข5 min read

Async/await didn't change how JavaScript handles async work. It changed how you write it - and that difference matters more than you'd think.

The Story So Far

We've been on a journey. First, callbacks - a function passed around to handle async work. Then promises - a cleaner way to represent future values, with .then() and .catch() to handle outcomes.

Step 1: Callbacks - Raw mechanism. Gets the job done. Nests badly.
Step 2: Promises - Flat chains. Central error handling. Still verbose.
Step 3: Async/Await - Reads like sync code. Built on promises.

Promises were a massive improvement. But they still have one awkward quality: you're always writing .then() after .then(). What if async code could just look like normal code?

That's what async/await is.

It's Just Syntactic Sugar

Before anything else, the most important thing to understand:

async/await is not a new async system. It's syntax built on top of promises.

Under the hood, every async function returns a promise. Every await expression waits for a promise to resolve. Nothing about how JavaScript handles async work has changed. What changed is how you write it.

// These two do the exact same thing

// With promises
function getUser() {
  return fetch("/api/user").then(res => res.json());
}

// With async/await
async function getUser() {
  const res = await fetch("/api/user");
  return res.json();
}

Same outcome. The async/await version just reads like synchronous code - one line at a time, top to bottom.

How Async Functions Work

You make a function async by putting the async keyword in front of it:

async function greet() {
  return "Hello!";
}

// greet() returns a promise โ€” not a plain string
greet().then(value => console.log(value)); // "Hello!"

Even though you returned a plain string, greet() returns a promise that resolves to "Hello!". An async function always returns a promise - always. Even if you return a plain value, JavaScript warps it in Promise.resolve() automatically.

The Await Keyword

await can only be used inside an async function. It pauses the execution of that function until the promise resolves, then gives you the resolved value directly.

async function fetchUser() {
  const response = await fetch("https://api.example.com/user");
  const user = await response.json();
  console.log(user);
}

Read that line by line: call fetch(), wait for the response. Call .json(), wait for the data. Log it. It reads exactly like synchronous code - no callbacks, no .then(), just variables holding values one step at a time.

๐Ÿ’ก
Important: await doesn't block the entire JavaScript thread. It only pauses that specific async function. Everything else keeps running normally.

Error Handling with try/catch

With promises, you handle errors using .catch(). With async/await, you use a plain try/catch block - the same syntax you'd use for any synchronous error.

async function fetchUser() {
  try {
    const response = await fetch("https://api.example.com/user");
    const user = await response.json();
    console.log(user);
  } catch (error) {
    console.log("Something went wrong:", error);
  } finally {
    console.log("Request finished");
  }
}

If anything inside try throws - network error, JSON parsing failure, anything - it jumps straight to catch. One block handles everything. The error handling sits right next to the code it's guarding, in a pattern every developer already knows.

Comparison: All Three Approaches

Same task - fetch a user, then fetch their posts - written three ways:

  1. CALLBACKS

    getUser(function(err, user) {
      if (err) return handleError(err);
      getPosts(user.id, function(err, posts) {
        if (err) return handleError(err);
        displayPosts(posts);
      });
    });
    
  2. PROMISES

    getUser()
      .then(user => getPosts(user.id))
      .then(posts => displayPosts(posts))
      .catch(err => handleError(err));
    
  3. ASYNC/AWAIT

    async function loadUserPosts() {
      try {
        const user = await getUser();
        const posts = await getPosts(user.id);
        displayPosts(posts);
      } catch (err) {
        handleError(err);
      }
    }
    

Same logic, three different shapes. Async/await wins on readability - it looks like a plain list of steps, which is exactly what it is.

A Few Real Patterns

Running operations in parallel

If your async operations don't depend on each other, don't await them one by one - that makes them sequential when they could run simultaneously.

// Slow โ€” waits for each before starting the next
const user          = await getUser();
const settings      = await getSettings();
const notifications = await getNotifications();

// Fast โ€” all three start at the same time
const [user, settings, notifications] = await Promise.all([
  getUser(),
  getSettings(),
  getNotifications()
]);

Async in loops - a gotcha

๐Ÿ’ก
Watch out: await doesn't work as expected inside .forEach(). Use a for...of loop instead when you need sequential async iteration.
// Doesn't work โ€” forEach doesn't wait
ids.forEach(async (id) => {
  const user = await getUser(id);
});

// Works โ€” for...of waits at each step
for (const id of ids) {
  const user = await getUser(id);
  console.log(user);
}

Conclusion

You now have all three
Callbacks are the raw mechanism. Promises gave it structure. Async/await gave it readability. You're not learning three separate things - you're learning one thing expressed in progressively cleaner syntax.

Want More?

Blog: https://blogs.kanishk.codes
Twitter: https://x.com/kanishk_fr
LinkedIn: https://linkedin.com/in/kanishk-chandna
Instagram: https://instagram.com/kanishk__fr