DEV Community

Cover image for Async/Await in JavaScript: Writing Cleaner Asynchronous Code
Pratham
Pratham

Posted on

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Asynchronous code that reads like synchronous code — because sometimes the best upgrade is better syntax.


We've come a long way. Callbacks taught us how JavaScript handles async operations. Promises gave us flat chains and centralized error handling. But let me show you the next evolution:

// Promise chain
getUser(1)
  .then((user) => getOrders(user.id))
  .then((orders) => getShipping(orders[0].id))
  .then((shipping) => console.log(shipping.address))
  .catch((err) => console.log(err));

// Async/await — same logic
async function showShippingAddress() {
  try {
    const user = await getUser(1);
    const orders = await getOrders(user.id);
    const shipping = await getShipping(orders[0].id);
    console.log(shipping.address);
  } catch (err) {
    console.log(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at the async/await version. No .then(). No chaining. No callbacks. It reads exactly like synchronous code — step 1, step 2, step 3 — but it's fully asynchronous under the hood.

When I first saw this in the ChaiCode Web Dev Cohort 2026, I thought it was too good to be true. But it's real, and it's now the standard way to write async JavaScript. Let me break it down.


Why Was Async/Await Introduced?

Promises were a massive improvement over callbacks. But they still have a learning curve. .then() chains, returning values between steps, understanding how .catch() propagates — it's all manageable, but it's not intuitive.

The JavaScript community asked: "What if we could write async code that looks like regular, top-to-bottom, synchronous code?"

That's exactly what async/await is. It was introduced in ES2017 (ES8) as syntactic sugar on top of Promises. It doesn't replace Promises — it uses them. Every async function returns a Promise. Every await unwraps a Promise. It's Promises with a friendlier face.

The Evolution

Callbacks (ES5):
  getUser(1, (err, user) => {
    getOrders(user.id, (err, orders) => {
      getShipping(orders[0].id, (err, ship) => {
        console.log(ship.address);
      });
    });
  });

Promises (ES6):
  getUser(1)
    .then(user => getOrders(user.id))
    .then(orders => getShipping(orders[0].id))
    .then(ship => console.log(ship.address))
    .catch(err => console.log(err));

Async/Await (ES2017):
  const user = await getUser(1);
  const orders = await getOrders(user.id);
  const shipping = await getShipping(orders[0].id);
  console.log(shipping.address);

Same async operations. Each version is cleaner than the last.
Enter fullscreen mode Exit fullscreen mode

How Async Functions Work

An async function is a function declared with the async keyword. Two things happen when you add async:

  1. The function automatically returns a Promise
  2. You're allowed to use the await keyword inside it

Basic Syntax

async function fetchData() {
  return "Hello from async!";
}

// This is identical to:
function fetchData() {
  return Promise.resolve("Hello from async!");
}
Enter fullscreen mode Exit fullscreen mode

Whatever you return from an async function gets wrapped in a resolved Promise automatically.

async function getName() {
  return "Pratham";
}

getName().then((name) => console.log(name)); // "Pratham"
Enter fullscreen mode Exit fullscreen mode

You can also use arrow function syntax:

const getName = async () => {
  return "Pratham";
};
Enter fullscreen mode Exit fullscreen mode

Proof That Async Functions Return Promises

async function example() {
  return 42;
}

const result = example();
console.log(result); // Promise { <fulfilled>: 42 }
console.log(result instanceof Promise); // true
Enter fullscreen mode Exit fullscreen mode

It's a Promise. Always. Even if you return a plain value, it gets wrapped.


The await Keyword

await is the magic word. It pauses the execution of the async function until the Promise it's waiting on settles (fulfills or rejects). Then it unwraps the result and gives you the actual value.

Without await

async function getUser() {
  const response = fetch("https://jsonplaceholder.typicode.com/users/1");
  console.log(response); // Promise { <pending> } — not the data!
}
Enter fullscreen mode Exit fullscreen mode

With await

async function getUser() {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/users/1",
  );
  const user = await response.json();
  console.log(user.name); // "Leanne Graham" — the actual data!
}

getUser();
Enter fullscreen mode Exit fullscreen mode

await says: "Pause here. Wait for this Promise to resolve. Then give me the value."

Key Rules for await

  1. await can only be used inside async functions (or at the top level of a module)
  2. await pauses the async function, not the entire program — other code keeps running
  3. await works with any Promise — built-in ones like fetch() or your own

await Doesn't Block Everything

This is crucial to understand. When an async function hits await, it pauses that function and gives control back to the rest of the program. Other code continues running.

async function slowTask() {
  console.log("A: Starting slow task...");
  await new Promise((resolve) => setTimeout(resolve, 2000));
  console.log("B: Slow task done!");
}

console.log("1: Before");
slowTask();
console.log("2: After");
Enter fullscreen mode Exit fullscreen mode

Output:

1: Before
A: Starting slow task...
2: After
B: Slow task done!      ← 2 seconds later
Enter fullscreen mode Exit fullscreen mode

"2: After" prints before "B: Slow task done!" because await only pauses the async function, not the calling code.


Async Function Execution Flow

console.log("Start");

async function doWork() {
  console.log("Step 1");
  const data = await fetchSomething();    pauses HERE
  console.log("Step 2:", data);            runs after await resolves
}

doWork();
console.log("End");

Execution:
─────────────────────────────────────────────────
  "Start"          synchronous
  "Step 1"         synchronous (inside async, before await)
  "End"            synchronous (doWork paused at await, control returned)
  ...waiting for fetchSomething()...
  "Step 2: data"   runs when the Promise resolves
─────────────────────────────────────────────────

Key insight: Everything BEFORE the first await runs synchronously.
At the first await, the function pauses and control returns to the caller.
Enter fullscreen mode Exit fullscreen mode

Error Handling with try/catch

With Promises, you use .catch(). With async/await, you use try/catch — the same error handling syntax you use for synchronous code.

Basic Error Handling

async function fetchUser(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);

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

    const user = await response.json();
    console.log(`User: ${user.name}`);
  } catch (error) {
    console.log(`Failed to fetch user: ${error.message}`);
  }
}

fetchUser(1);
Enter fullscreen mode Exit fullscreen mode

If anything inside the try block throws an error or if any awaited Promise rejects, execution immediately jumps to the catch block.

With finally

async function loadData() {
  console.log("⏳ Loading...");

  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const post = await response.json();
    console.log(`✅ Loaded: ${post.title}`);
  } catch (error) {
    console.log(`❌ Error: ${error.message}`);
  } finally {
    console.log("🏁 Loading complete — cleanup done.");
  }
}

loadData();
Enter fullscreen mode Exit fullscreen mode

finally runs no matter what — perfect for hiding loading spinners, closing connections, or any cleanup.

Handling Multiple Awaits

Each await in a try block is covered by the same catch. If step 2 fails, you don't need a separate error handler:

async function processOrder() {
  try {
    const user = await getUser(1);
    const orders = await getOrders(user.id); // If THIS fails...
    const shipping = await getShipping(orders[0].id);
    console.log(`Shipping to: ${shipping.address}`);
  } catch (error) {
    console.log(`Something failed: ${error.message}`); // ...it's caught HERE
  }
}
Enter fullscreen mode Exit fullscreen mode

Compare this to the Promise version where .catch() at the end catches errors from any step — same behavior, but try/catch reads more naturally.


Promise vs Async/Await — Side by Side

Sequential Operations

// Promise chain
function getProfile() {
  return getUser(1)
    .then((user) => {
      return getAvatar(user.avatarId);
    })
    .then((avatar) => {
      console.log(`Avatar URL: ${avatar.url}`);
    })
    .catch((err) => {
      console.log(err);
    });
}

// Async/await
async function getProfile() {
  try {
    const user = await getUser(1);
    const avatar = await getAvatar(user.avatarId);
    console.log(`Avatar URL: ${avatar.url}`);
  } catch (err) {
    console.log(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

// Promise
fetchData()
  .then((data) => processData(data))
  .then((result) => saveResult(result))
  .catch((err) => console.log("Error:", err));

// Async/await
async function handleData() {
  try {
    const data = await fetchData();
    const result = await processData(data);
    await saveResult(result);
  } catch (err) {
    console.log("Error:", err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Comparison Table

Feature Promises (.then()) Async/Await
Syntax .then() / .catch() chains await + try/catch
Readability Good — but chains can get long Excellent — reads like sync code
Error handling .catch() at the end try/catch — familiar syntax
Debugging Harder — stack traces less clear Easier — breakpoints work normally
Line-by-line Need to follow the chain Each await is its own line
Under the hood Promises Also Promises (syntactic sugar)
When to use Simple chains, .all(), .race() Sequential async, most use cases

Promise vs Async/Await Flow

PROMISE CHAIN:
  getUser(1) ──→ .then() ──→ .then() ──→ .then() ──→ .catch()
                   │            │            │           │
              returns a    returns a    returns a    catches any
              Promise      Promise      Promise      rejection

ASYNC/AWAIT:
  async function() {
    const user = await getUser(1);      ← pause, get value
    const orders = await getOrders();   ← pause, get value
    const ship = await getShipping();   ← pause, get value
    console.log(ship.address);          ← use the value
  }

Same thing. Different syntax. Async/await just reads top-to-bottom.
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

Pattern 1: Fetching Data from a Real API

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

    console.log(`Name: ${user.name}`);
    console.log(`Email: ${user.email}`);
    console.log(`City: ${user.address.city}`);
  } catch (error) {
    console.log("Failed to fetch user:", error.message);
  }
}

displayUser();
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Sequential Operations

async function morningRoutine() {
  const coffee = await makeCoffee(); // Wait for coffee first
  const breakfast = await cookBreakfast(); // Then cook breakfast
  const news = await fetchNews(); // Then fetch news

  console.log(`Enjoying ${coffee} with ${breakfast} while reading ${news}`);
}
Enter fullscreen mode Exit fullscreen mode

Each step waits for the previous one. Order matters here.

Pattern 3: Parallel Operations with Promise.all()

Sometimes steps are independent — they don't depend on each other. Running them sequentially wastes time:

// ❌ Sequential — slow (3 seconds total)
async function loadDashboard() {
  const user = await fetchUser(); // 1 second
  const posts = await fetchPosts(); // 1 second
  const notifications = await fetchNotifications(); // 1 second
  // Total: 3 seconds 😴
}

// ✅ Parallel — fast (1 second total)
async function loadDashboard() {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(), // 1 second ─┐
    fetchPosts(), // 1 second  ├─ all run simultaneously
    fetchNotifications(), // 1 second ─┘
  ]);
  // Total: ~1 second 🚀
}
Enter fullscreen mode Exit fullscreen mode

Promise.all() runs all three fetches at the same time and waits for all of them to finish. If any one fails, the entire Promise.all() rejects.

Pattern 4: Async in Arrow Functions

const getUser = async (id) => {
  const response = await fetch(`https://api.example.com/users/${id}`);
  return response.json();
};

const user = await getUser(1);
Enter fullscreen mode Exit fullscreen mode

Let's Practice: Hands-On Assignment

Part 1: Basic Async/Await

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function countdown() {
  console.log("3...");
  await delay(1000);
  console.log("2...");
  await delay(1000);
  console.log("1...");
  await delay(1000);
  console.log("🚀 Go!");
}

countdown();
Enter fullscreen mode Exit fullscreen mode

Part 2: Fetch Real Data

async function getPost() {
  try {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/posts/1",
    );
    const post = await response.json();

    console.log(`Title: ${post.title}`);
    console.log(`Body: ${post.body}`);
  } catch (error) {
    console.log("Error:", error.message);
  }
}

getPost();
Enter fullscreen mode Exit fullscreen mode

Part 3: Sequential vs Parallel

const fakeAPI = (name, ms) =>
  new Promise((resolve) => {
    setTimeout(() => resolve(`${name} loaded`), ms);
  });

// Sequential — runs one after another
async function sequential() {
  console.time("Sequential");
  const a = await fakeAPI("Users", 1000);
  const b = await fakeAPI("Posts", 1000);
  const c = await fakeAPI("Comments", 1000);
  console.log(a, b, c);
  console.timeEnd("Sequential"); // ~3 seconds
}

// Parallel — runs all at once
async function parallel() {
  console.time("Parallel");
  const [a, b, c] = await Promise.all([
    fakeAPI("Users", 1000),
    fakeAPI("Posts", 1000),
    fakeAPI("Comments", 1000),
  ]);
  console.log(a, b, c);
  console.timeEnd("Parallel"); // ~1 second
}

sequential();
// parallel();  // Try this one too!
Enter fullscreen mode Exit fullscreen mode

Part 4: Error Handling

async function riskyFetch() {
  try {
    const response = await fetch("https://invalid-url.example.com");
    const data = await response.json();
    console.log("Data:", data);
  } catch (error) {
    console.log("Caught an error:", error.message);
  } finally {
    console.log("Fetch attempt complete.");
  }
}

riskyFetch();
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Async/await is syntactic sugar over Promises. It doesn't replace them — it makes them easier to write and read.
  2. async makes a function return a Promise. await pauses the function until a Promise resolves, then gives you the value.
  3. await only pauses the async function, not the entire program. Other code continues running while the function waits.
  4. Error handling uses try/catch — the same familiar syntax from synchronous JavaScript. Add finally for cleanup.
  5. Use Promise.all() for independent operations that can run in parallel. Use sequential await when each step depends on the previous one.

Wrapping Up

Async/await is the final piece of the async JavaScript puzzle. Callbacks showed us the concept. Promises gave us structure. Async/await gave us readability. The fact that you can write asynchronous code that looks and reads like regular, top-to-bottom JavaScript — with proper error handling and no nesting — is genuinely one of the best things about modern JavaScript.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. The journey from callbacks → Promises → async/await has been one of the most satisfying progressions in the cohort. Each one builds on the last, and by the time you reach async/await, it all just clicks.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles coming as the journey continues.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)