Skip to main content

Async/Await in JavaScript: Writing Asynchronous Code That Reads Like Synchronous Code

Updated 13 min readMarkdown
Async/Await in JavaScript: Writing Asynchronous Code That Reads Like Synchronous Code

JavaScript's async story has evolved through three phases. First came callbacks — functions passed to be called later. Then came Promises — objects representing a future value, chainable with .then(). Then came async/await — syntax that lets you write asynchronous code that reads almost exactly like synchronous code.

async/await did not replace Promises. It is built on top of them. Every async function returns a Promise. Every await expression pauses and waits for a Promise. But the way you write and read that code changes dramatically — and that is the point.


Why async/await Was Introduced

Promises improved on callbacks significantly. Instead of nesting functions inside functions, you chain .then() calls in sequence:

// Callbacks — nested, hard to follow
getUser(id, function(err, user) {
  if (err) return handleError(err);
  getOrders(user.id, function(err, orders) {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, function(err, details) {
      if (err) return handleError(err);
      console.log(details);
    });
  });
});

// Promises — chained, better
getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => console.log(details))
  .catch(err => handleError(err));

The Promise chain is already a large improvement. But it still has friction. The .then() wrapper around each step is visual noise. The variable from one step cannot easily be referenced in a later step without extra contortions. Error handling with .catch() applies to the whole chain, making it harder to handle specific failures differently.

async/await solves the remaining friction:

// async/await — reads like synchronous code
async function loadOrderDetails(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    console.log(details);
  } catch (err) {
    handleError(err);
  }
}

The logic is identical. The Promise mechanics are unchanged underneath. But the code reads top to bottom, variables are accessible anywhere in the block, and error handling uses try...catch — the same pattern used for synchronous errors. There is no new mental model to learn. The existing one extends naturally.


How async Functions Work

The async keyword before a function declaration changes two things: it makes the function return a Promise, and it allows the await keyword to be used inside it.

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

greet(); // returns a Promise
greet().then(msg => console.log(msg)); // Hello, Priya!

Even though greet returns a plain string, async wraps it in a resolved Promise automatically. Returning a value from an async function is equivalent to Promise.resolve(value).

If you explicitly return a Promise from an async function, it is not double-wrapped — the Promise is returned as-is:

async function fetchData() {
  return Promise.resolve(42);
}

fetchData().then(val => console.log(val)); // 42 — not Promise.resolve(Promise.resolve(42))

async functions can be written in any function form:

// Function declaration
async function fetchUser(id) { ... }

// Function expression
const fetchUser = async function(id) { ... }

// Arrow function
const fetchUser = async (id) => { ... }

// Method in a class or object
class UserService {
  async getUser(id) { ... }
}

The keyword always goes before the function keyword or parameter list. For arrow functions, it goes before the parameters.

What happens when an async function throws

If an async function throws an error (or returns a rejected Promise), the returned Promise is rejected with that error:

async function fail() {
  throw new Error("Something went wrong");
}

fail().catch(err => console.log(err.message)); // Something went wrong

This is the Promise equivalent of Promise.reject(new Error("...")). Uncaught rejections in async functions behave exactly like uncaught Promise rejections.


The await Keyword

await can only be used inside an async function (or at the top level of an ES module). It pauses execution of the async function until the Promise it precedes is settled — either resolved or rejected — and then resumes.

async function example() {
  console.log("1. Before await");
  const result = await Promise.resolve("hello");
  console.log("2. After await:", result);
  console.log("3. Done");
}

example();
console.log("4. After calling example()");

Output:

1. Before await
4. After calling example()
2. After await: hello
3. Done

This output is the key to understanding await. When example() is called, it runs until it hits await. At that point, it suspends — it yields control back to the surrounding code. "4. After calling example()" runs while example is paused. When the awaited Promise resolves, example resumes from where it left off.

The function does not block the thread. It pauses itself, not JavaScript. Other code continues to run while the async operation completes.

await unwraps the Promise value

The expression await promise evaluates to the resolved value of the Promise — not the Promise itself:

async function getNumber() {
  const value = await Promise.resolve(42);
  console.log(value); // 42 — not Promise { 42 }
  return value;
}

Without await, you would have a Promise object. With await, you have the value inside it. This is the main ergonomic benefit — you work with values directly rather than always wrapping and unwrapping them.

await with non-Promise values

You can await any value. If the value is not a Promise, await treats it as an already-resolved Promise and continues immediately:

async function example() {
  const x = await 42;     // immediately resolves
  const y = await "hello"; // same
  console.log(x, y); // 42  hello
}

This is useful in functions that accept either a value or a Promise — you can await the argument without special-casing it.


Sequential vs Parallel Execution

One of the most important things to understand about await is that awaiting each operation in sequence means they run one after the other — the next one does not start until the previous one finishes.

async function sequential() {
  const a = await fetchA(); // waits for fetchA to finish
  const b = await fetchB(); // only then starts fetchB
  return [a, b];
}

If fetchA and fetchB are independent, this wastes time. They could run in parallel. To run them concurrently, start both Promises before awaiting either:

async function parallel() {
  const promiseA = fetchA(); // starts immediately
  const promiseB = fetchB(); // starts immediately, does not wait for A

  const a = await promiseA; // waits for A
  const b = await promiseB; // waits for B (may already be done)
  return [a, b];
}

Or more cleanly with Promise.all:

async function parallel() {
  const [a, b] = await Promise.all([fetchA(), fetchB()]);
  return [a, b];
}

Promise.all takes an array of Promises, runs them concurrently, and resolves when all of them resolve. If any one rejects, Promise.all rejects immediately with that reason.

Sequential await is correct when each step depends on the result of the previous. Parallel Promise.all is correct when steps are independent.


Error Handling with async Code

Error handling in async/await uses standard try...catch blocks. When an awaited Promise rejects, it throws at the await expression — exactly like a synchronous throw.

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: could not fetch user`);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Failed to fetch user:", error.message);
    return null;
  }
}

The try...catch wraps all the await expressions together. If fetch rejects (network error), or if response.json() throws (malformed JSON), or if you explicitly throw, all of it is caught in the same catch block.

Handling specific operations differently

If you need different handling for different steps, use separate try...catch blocks:

async function loadDashboard(userId) {
  let user;
  try {
    user = await fetchUser(userId);
  } catch {
    return showError("Could not load your account.");
  }

  try {
    const data = await fetchDashboardData(user.id);
    render(data);
  } catch {
    render(getDefaultDashboard()); // fallback — still show something
  }
}

The first error is fatal — if we cannot identify the user, there is nothing to show. The second error has a fallback — the dashboard can still render with default data.

The .catch() alternative

You can also handle errors on the Promise returned by the async function:

async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

fetchUser(1)
  .then(user => console.log(user))
  .catch(err => console.error(err));

Or attach .catch() to individual awaited Promises for per-operation fallbacks:

async function loadPage() {
  const user = await fetchUser(id).catch(() => null);
  const posts = await fetchPosts().catch(() => []);

  if (!user) return showLoginPage();
  render(user, posts);
}

This pattern — await promise.catch(() => defaultValue) — gives each operation a fallback without nesting try...catch blocks everywhere.


async/await vs Promises: A Direct Comparison

The two styles express the same logic. Here is the same sequence of dependent async operations written both ways:

With Promises

function loadUserProfile(userId) {
  return getUser(userId)
    .then(user => {
      return getPosts(user.id).then(posts => {
        return getComments(posts[0].id).then(comments => {
          return { user, posts, comments };
        });
      });
    })
    .catch(err => {
      console.error("Failed to load profile:", err);
      return null;
    });
}

The nesting creeps back in as soon as you need a value from one step in a later step. user is available in the outer .then, but posts is not available in the same scope as comments unless you nest them.

You can flatten this with intermediate variables, but it gets verbose:

function loadUserProfile(userId) {
  let savedUser;
  let savedPosts;

  return getUser(userId)
    .then(user => { savedUser = user; return getPosts(user.id); })
    .then(posts => { savedPosts = posts; return getComments(posts[0].id); })
    .then(comments => ({ user: savedUser, posts: savedPosts, comments }))
    .catch(err => { console.error("Failed:", err); return null; });
}

The intermediate variables (savedUser, savedPosts) are pollution that exists only to bridge the scope gap between .then() calls.

With async/await

async function loadUserProfile(userId) {
  try {
    const user = await getUser(userId);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    return { user, posts, comments };
  } catch (err) {
    console.error("Failed to load profile:", err);
    return null;
  }
}

All variables are in the same scope. No intermediate storage. No nesting. The logic reads exactly as it would if these operations were synchronous. The try...catch handles all errors in one place.

Summary comparison

Aspect Promises async/await
Syntax .then() chains await expressions
Readability Good for simple chains Better for sequential logic
Variable scope Each .then() has its own scope All in the same block
Error handling .catch() at end of chain try...catch blocks
Debugging Stack traces can be incomplete Cleaner stack traces
Parallel operations Promise.all() await Promise.all()
Return value Promise Promise (async function returns one)

They are not mutually exclusive. Real code often uses both — async/await for the main flow and Promise.all or .catch() where they fit more naturally.


Practical Examples

Fetching data with async/await

async function getWeather(city) {
  const url = `https://api.weather.example.com/city/${city}`;

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Could not fetch weather for ${city}`);
  }

  const data = await response.json();
  return data;
}

async function displayWeather() {
  try {
    const weather = await getWeather("Mumbai");
    console.log(`Temperature: ${weather.temp}°C`);
    console.log(`Condition: ${weather.description}`);
  } catch (error) {
    console.log("Unable to load weather:", error.message);
  }
}

displayWeather();

Loading multiple resources in parallel

async function loadPageData(userId) {
  try {
    const [profile, feed, notifications] = await Promise.all([
      fetchProfile(userId),
      fetchFeed(userId),
      fetchNotifications(userId)
    ]);

    return { profile, feed, notifications };
  } catch (error) {
    console.error("Failed to load page data:", error);
    return null;
  }
}

All three requests fire at the same time. The function waits until all three complete before continuing. If any one fails, the whole operation is treated as a failure.

Retry logic with async/await

async function fetchWithRetry(url, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      if (attempt === retries) throw error;
      console.log(`Attempt ${attempt} failed. Retrying...`);
      await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
    }
  }
}

await new Promise(resolve => setTimeout(resolve, delay)) is the async/await way to wait for a fixed duration. The retry logic reads naturally as a loop — something that would be significantly harder to express cleanly with Promise chains.


Common Pitfalls

Forgetting await:

async function fetchUser(id) {
  const response = fetch(`/api/users/${id}`); // missing await
  return response.json(); // error: response is a Promise, not a Response
}

Without await, response is the Promise itself, not the resolved value. The .json() call fails because Promise objects do not have that method. The error message is confusing and the cause is not obvious. This is one of the most common async bugs.

Using await in a regular forEach loop:

// This does not work as expected
async function processAll(ids) {
  ids.forEach(async (id) => {
    const user = await fetchUser(id); // awaited inside the callback, not the outer function
    console.log(user);
  });
  console.log("Done"); // prints before any user is logged
}

forEach does not await its callback. Each iteration starts an async operation and moves on immediately. Use for...of when you need sequential awaiting, or Promise.all for parallel:

// Sequential
async function processAll(ids) {
  for (const id of ids) {
    const user = await fetchUser(id);
    console.log(user);
  }
}

// Parallel
async function processAll(ids) {
  const users = await Promise.all(ids.map(id => fetchUser(id)));
  users.forEach(user => console.log(user));
}

Not handling rejections:

async function load() {
  const data = await fetch("/api/data"); // rejection goes unhandled
  return data.json();
}

load(); // if this rejects, it's an unhandled Promise rejection

Every async function call should be wrapped in try...catch, or have .catch() attached to it. Unhandled Promise rejections surface as warnings in Node.js and may crash the process in future versions.


Wrapping Up

async/await is the current standard for writing asynchronous JavaScript. It is syntactic sugar over Promises — the execution model is identical, but the code you write is dramatically more readable. Sequential operations look sequential. Variables are in scope where you use them. Error handling is standard try...catch.

The core rules are simple:

  • Mark a function async to return a Promise and use await inside it

  • await pauses the function, not the thread

  • Sequential await runs operations one after the other; Promise.all runs them in parallel

  • Wrap awaited operations in try...catch to handle failures

Understanding that async/await is built on Promises — not separate from them — is what lets you use both comfortably. Some situations call for Promise.all, Promise.race, or .catch() chaining. Most sequential async logic is cleaner with await. The two styles are complementary, and fluency with both is what real-world async code requires.

No comments yet. Be the first to comment.

More from this blog