DEV Community

Harman Panwar
Harman Panwar

Posted on

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Async/Await in JavaScript: Writing Asynchronous Code That Looks Synchronous

JavaScript's asynchronous evolution moved from callbacks to promises to async/await — each step making async code more readable and maintainable. Async/await, introduced in ES2017 (ES8), is not a new concept or a replacement for promises. It is syntactic sugar — a cleaner way to write promise-based code that reads like synchronous code while preserving all of JavaScript's non-blocking behavior. This guide explains why it was introduced, how it works under the hood, and how it transforms the way you write asynchronous JavaScript.


Why Async/Await Was Introduced

The Promise Readability Problem

Promises solved callback hell, but they introduced their own readability challenges. Complex promise chains become verbose, and the mental model of chaining .then() calls is not intuitive for developers coming from synchronous languages.

The Promise Chain Complexity

function getUserData(userId) {
  return fetchUser(userId)
    .then(user => {
      console.log("User found:", user.name);
      return fetchOrders(user.id);
    })
    .then(orders => {
      console.log("Orders found:", orders.length);
      return fetchShippingStatus(orders[0].id);
    })
    .then(status => {
      console.log("Shipping status:", status);
      return { user, orders, status }; // Wait — 'user' is not in scope here!
    })
    .catch(error => {
      console.error("Something failed:", error);
    });
}
Enter fullscreen mode Exit fullscreen mode

The pain points:

  • Variables from earlier .then() blocks are not accessible in later blocks
  • Scoping issues force you to declare outer variables or nest promises
  • Error handling with .catch() catches all errors in the chain, making it hard to handle specific step failures
  • The visual flow is vertical but the execution flow is fragmented
  • Debugging is difficult because breakpoints behave unexpectedly across .then() boundaries

The Mental Overhead

Reading promise chains requires holding a mental stack of "what resolves to what." Each .then() creates a new scope. Each return value flows to the next link. For simple cases, this is fine. For business logic with conditionals, loops, and parallel operations, the cognitive load becomes significant.

What Developers Wanted

Developers wanted to write asynchronous code that:

  • Reads top-to-bottom like synchronous code
  • Uses familiar control flow (if, for, try/catch)
  • Allows variables to remain in scope across multiple async operations
  • Can be debugged with standard breakpoints
  • Does not require learning a new pattern (promises) on top of another pattern (callbacks)

Async/await delivers exactly this. It does not replace promises — it makes promises readable.


How Async Functions Work

The async Keyword

Placing async before a function declaration does two things:

  1. It makes the function always return a promise
  2. It allows the use of await inside the function
// A regular function returns its value directly
function regularFunction() {
  return "Hello";
}

const result1 = regularFunction();
console.log(result1); // "Hello"

// An async function wraps its return value in a promise
async function asyncFunction() {
  return "Hello";
}

const result2 = asyncFunction();
console.log(result2); // Promise { 'Hello' }

result2.then(value => console.log(value)); // "Hello"
Enter fullscreen mode Exit fullscreen mode

Key insight: An async function automatically wraps its return value in a resolved promise. If you return a promise, it is returned as-is. If you throw an error, it returns a rejected promise.

What Happens Under the Hood

When JavaScript encounters an async function, the engine transforms it internally into a state machine that uses promises. The code you write:

async function fetchData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  return data;
}
Enter fullscreen mode Exit fullscreen mode

Is conceptually equivalent to:

function fetchData() {
  return fetch('/api/data')
    .then(response => response.json())
    .then(data => data);
}
Enter fullscreen mode Exit fullscreen mode

The async/await syntax is syntactic sugar — it compiles to promise-based code. The engine handles the .then() chaining for you, but the underlying mechanism is identical.

Async Functions Always Return Promises

async function returnValue() {
  return 42; // Returns Promise.resolve(42)
}

async function returnPromise() {
  return fetch('/api/data'); // Returns the promise directly
}

async function throwError() {
  throw new Error("Something broke"); // Returns Promise.reject(Error)
}

// All three return promises
console.log(returnValue());      // Promise { 42 }
console.log(returnPromise());    // Promise { <pending> }
console.log(throwError());       // Promise { <rejected> Error }
Enter fullscreen mode Exit fullscreen mode

Await Keyword Concept

What await Does

The await keyword can only be used inside an async function. It pauses the execution of the async function until the awaited promise settles, then returns the resolved value.

Critical distinction: await does not block the main thread. It only pauses the current async function. Other JavaScript code continues running normally.

async function makeBreakfast() {
  console.log("1. Start coffee");

  const coffee = await brewCoffee(); // Pauses here, but main thread is FREE
  console.log("2. Coffee ready:", coffee);

  const toast = await makeToast();   // Pauses again, main thread still FREE
  console.log("3. Toast ready:", toast);

  return "Breakfast complete!";
}

// Main thread continues immediately
console.log("4. Called makeBreakfast");
makeBreakfast().then(result => console.log("5.", result));
console.log("6. Main thread keeps going");

// Output:
// 4. Called makeBreakfast
// 1. Start coffee
// 6. Main thread keeps going
// [coffee brews in background]
// 2. Coffee ready: Dark roast
// [toast toasts in background]
// 3. Toast ready: Golden brown
// 5. Breakfast complete!
Enter fullscreen mode Exit fullscreen mode

What happened:

  • Line 4 prints immediately
  • makeBreakfast() starts, prints line 1
  • await brewCoffee() pauses the function but not the main thread
  • Line 6 prints while coffee is still brewing
  • When coffee resolves, line 2 prints
  • await makeToast() pauses again
  • When toast resolves, line 3 prints
  • The function returns, .then() fires, line 5 prints

Await with Non-Promise Values

await accepts any value. If the value is not a promise, it is wrapped in a resolved promise automatically:

async function example() {
  const a = await 42;           // Equivalent to await Promise.resolve(42)
  const b = await "hello";      // Equivalent to await Promise.resolve("hello")
  const c = await Promise.resolve(true);

  console.log(a, b, c); // 42 "hello" true
}
Enter fullscreen mode Exit fullscreen mode

This is rarely useful but demonstrates that await is forgiving — it always expects a promise-like value and handles non-promises gracefully.

Sequential vs Parallel Await

Sequential (one after another — total time adds up):

async function sequential() {
  const user = await fetchUser();      // 100ms
  const orders = await fetchOrders();   // 200ms
  const stats = await fetchStats();     // 150ms
  return { user, orders, stats };
  // Total: 450ms
}
Enter fullscreen mode Exit fullscreen mode

Parallel (all at once — total time is the slowest):

async function parallel() {
  // Start all three immediately, wait for all to finish
  const [user, orders, stats] = await Promise.all([
    fetchUser(),    // 100ms
    fetchOrders(),  // 200ms
    fetchStats()    // 150ms
  ]);
  return { user, orders, stats };
  // Total: 200ms (determined by slowest)
}
Enter fullscreen mode Exit fullscreen mode

Key insight: await is sequential by default. Use Promise.all() when operations are independent and can run concurrently.


Error Handling with Async Code

The try/catch Revival

Before async/await, promise error handling used .catch():

fetchUser(123)
  .then(user => fetchOrders(user.id))
  .then(orders => processOrders(orders))
  .catch(error => {
    // Catches ANY error in the chain
    // But which step failed? Hard to tell.
    console.error(error);
  });
Enter fullscreen mode Exit fullscreen mode

Async/await brings back familiar try/catch syntax with precise error control:

async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const stats = await fetchStats(user.id);
    return { user, orders, stats };
  } catch (error) {
    // Catches errors from ANY of the three awaits
    console.error("Dashboard load failed:", error.message);
    return { error: true, message: error.message };
  }
}
Enter fullscreen mode Exit fullscreen mode

Granular Error Handling

You can wrap individual await calls for specific error handling:

async function loadDashboard(userId) {
  let user, orders, stats;

  try {
    user = await fetchUser(userId);
  } catch (error) {
    console.error("Failed to load user:", error.message);
    return { error: "User not found" };
  }

  try {
    orders = await fetchOrders(user.id);
  } catch (error) {
    console.error("Failed to load orders:", error.message);
    orders = []; // Graceful fallback
  }

  try {
    stats = await fetchStats(user.id);
  } catch (error) {
    console.error("Failed to load stats:", error.message);
    stats = null; // Graceful fallback
  }

  return { user, orders, stats };
}
Enter fullscreen mode Exit fullscreen mode

Throwing Errors in Async Functions

async function validateUser(userId) {
  const user = await fetchUser(userId);

  if (!user) {
    throw new Error(`User ${userId} not found`);
  }

  if (!user.isActive) {
    throw new Error(`User ${userId} is suspended`);
  }

  return user;
}

// The thrown error becomes a rejected promise
try {
  const user = await validateUser(999);
} catch (error) {
  console.error(error.message); // "User 999 not found"
}
Enter fullscreen mode Exit fullscreen mode

Comparison with Promises

The Same Logic, Three Ways

Task: Fetch a user, then their orders, then return combined data.

Callbacks (The Old Way)

function getUserWithOrders(userId, callback) {
  fetchUser(userId, (err, user) => {
    if (err) return callback(err);

    fetchOrders(user.id, (err, orders) => {
      if (err) return callback(err);

      callback(null, { user, orders });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Lines: 10 | Readability: Poor | Error handling: Repetitive | Debugging: Difficult

Promises (The Middle Way)

function getUserWithOrders(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchOrders(user.id)
        .then(orders => ({ user, orders }));
    });
}
Enter fullscreen mode Exit fullscreen mode

Lines: 7 | Readability: Good | Error handling: Centralized .catch() | Debugging: Moderate

Async/Await (The Modern Way)

async function getUserWithOrders(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  return { user, orders };
}
Enter fullscreen mode Exit fullscreen mode

Lines: 5 | Readability: Excellent | Error handling: try/catch | Debugging: Easy (step through line by line)

Side-by-Side Comparison

Aspect Promises Async/Await
Syntax Chain of .then() calls Looks like synchronous code
Variable scope New scope per .then() block Single scope, variables persist
Conditionals Awkward (must return promises from if) Natural if/else blocks
Loops Complex (Promise.all() with mapping) Natural for loops with await
Error handling .catch() at end of chain try/catch around any block
Debugging Breakpoints jump between .then() blocks Step through line by line
Return value Always a promise Always a promise (from async function)
Learning curve New pattern to learn Familiar control flow

Conditional Logic Comparison

Promises with conditionals (awkward):

function processPayment(userId, amount) {
  return fetchUser(userId)
    .then(user => {
      if (user.balance < amount) {
        return Promise.reject(new Error("Insufficient funds"));
      }
      return deductBalance(user.id, amount);
    })
    .then(result => {
      if (result.status === "pending") {
        return notifyAdmin(result);
      }
      return sendReceipt(user.email, result);
    });
}
Enter fullscreen mode Exit fullscreen mode

Async/await with conditionals (natural):

async function processPayment(userId, amount) {
  const user = await fetchUser(userId);

  if (user.balance < amount) {
    throw new Error("Insufficient funds");
  }

  const result = await deductBalance(user.id, amount);

  if (result.status === "pending") {
    return await notifyAdmin(result);
  }

  return await sendReceipt(user.email, result);
}
Enter fullscreen mode Exit fullscreen mode

The async/await version reads like a script: fetch user, check balance, deduct, check status, notify or receipt. The promise version requires mentally tracking the chain and understanding where each .then() resolves.

Loop Comparison

Promises with loops (requires mapping):

function fetchAllUsers(userIds) {
  return Promise.all(
    userIds.map(id => fetchUser(id))
  );
}
Enter fullscreen mode Exit fullscreen mode

Async/await with loops (sequential or parallel):

// Sequential loop (one at a time)
async function fetchAllUsersSequential(userIds) {
  const users = [];
  for (const id of userIds) {
    const user = await fetchUser(id);
    users.push(user);
  }
  return users;
}

// Parallel loop (all at once)
async function fetchAllUsersParallel(userIds) {
  const promises = userIds.map(id => fetchUser(id));
  return await Promise.all(promises);
}
Enter fullscreen mode Exit fullscreen mode

Simple Async Examples

Example 1: Fetching Data from an API

async function getWeather(city) {
  try {
    const response = await fetch(`https://api.weather.com/v1/current?city=${city}`);

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

    const data = await response.json();
    return {
      temperature: data.temp,
      condition: data.condition,
      humidity: data.humidity
    };
  } catch (error) {
    console.error("Failed to fetch weather:", error.message);
    return null;
  }
}

// Usage
const weather = await getWeather("London");
if (weather) {
  console.log(`It is ${weather.temperature}°C and ${weather.condition}`);
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Reading a File

const fs = require('fs').promises;

async function readConfig() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    return config;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error("Config file not found, using defaults");
      return { port: 3000, env: 'development' };
    }
    throw error;
  }
}

// Usage
const config = await readConfig();
console.log("Server will run on port:", config.port);
Enter fullscreen mode Exit fullscreen mode

Example 3: Delayed Execution (Sleep)

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function countdown() {
  console.log("Starting countdown...");

  for (let i = 3; i > 0; i--) {
    console.log(i);
    await delay(1000); // Wait 1 second between counts
  }

  console.log("Go!");
}

countdown();
Enter fullscreen mode Exit fullscreen mode

Example 4: Retry Logic

async function fetchWithRetry(url, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) {
        return await response.json();
      }
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      if (attempt === maxAttempts) {
        throw new Error(`Failed after ${maxAttempts} attempts: ${error.message}`);
      }
      console.log(`Attempt ${attempt} failed, retrying...`);
      await delay(1000 * attempt); // Exponential backoff
    }
  }
}

// Usage
const data = await fetchWithRetry('/api/data');
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

Pitfall Why It Happens The Fix
Forgetting async Calling await in a non-async function Add async to the function declaration
Sequential when parallel needed Using multiple await statements instead of Promise.all() Use Promise.all() for independent operations
Not awaiting in loops array.forEach(async () => await ...) does not wait Use for...of loops or Promise.all(array.map(...))
Swallowing errors Empty catch blocks Always log or re-throw errors
Top-level await Using await outside any function Wrap in async IIFE: (async () => { await ... })()

Summary

Concept What It Means
Async/await Syntactic sugar over promises — cleaner syntax for the same underlying mechanism
async function A function that always returns a promise and enables await inside
await keyword Pauses the current async function until a promise settles, without blocking the main thread
try/catch Standard error handling that works naturally with async code
Syntactic sugar A nicer way to write code that compiles to the same underlying pattern (promises)

Async/await did not introduce new capabilities to JavaScript. It introduced a better way to express existing capabilities. Every async function is a promise factory. Every await is a .then() in disguise. The transformation is not in what JavaScript can do, but in how developers think about and write asynchronous code. The result is programs that are easier to read, easier to debug, and easier to maintain — while retaining all the non-blocking performance that makes JavaScript suitable for modern web applications.

Remember: Async/await is not magic and it is not a new paradigm. It is a translator — it takes code that looks synchronous and converts it into the same promise-based code you would have written manually. The engine does the translation. You get the readability.

Top comments (0)