Skip to main content

Command Palette

Search for a command to run...

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Published
6 min read

Async/await is syntactic sugar over Promises that makes async code read like synchronous code. It was introduced in ES2017 to solve the readability problems of nested .then() chains and callback hell. Error handling becomes a plain try/catch block.

Audience: Assumes basic knowledge of JavaScript functions and callbacks.


Problem

JavaScript is single-threaded. Every I/O operation — reading a file, querying a database, calling an API — is asynchronous. For years, we handled this with callbacks. That worked until logic became complex:

// callback-hell.js
getUser('user_42', (err, user) => {
  if (err) throw err;
  getUserOrders(user.id, (err, orders) => {
    if (err) throw err;
    getOrderItems(orders[0].id, (err, items) => {
      if (err) throw err;
      console.log(items); // finally.
    });
  });
});

Each nested level adds indentation, repeats error handling, and makes sequential logic look like a tree. Promises improved this — but chained .then() calls still don't read like normal code.


Why async/await Was Introduced

In ES2017, JavaScript introduced async/await as first-class syntax for working with Promises. The goal was simple: make async code look synchronous without blocking the event loop.

Key insight: async/await doesn't add new capabilities. Under the hood, an async function always returns a Promise. await is just a way to pause execution inside that function until a Promise resolves — without blocking other work.

Async function execution flow

Call async fn        await I/O            Promise resolves
(returns Promise) →  (fn pauses) →        (fn resumes) →  ...
                         ↕
              Event loop runs other callbacks

The function pauses at await but the thread stays free — other work continues in the meantime.


How async Functions Work

Any function prefixed with async implicitly wraps its return value in a Promise.resolve(). This means callers always get a Promise, whether or not you explicitly return one.

// async-basics.js

// Both functions are equivalent
function withPromise() {
  return Promise.resolve(42);
}

async function withAsync() {
  return 42; // auto-wrapped in Promise.resolve()
}

withAsync().then(console.log); // 42

// You can also await it
const val = await withAsync(); // 42

The await Keyword

await can only be used inside an async function (or at the top level of ES modules). It pauses the function at that line until the awaited Promise settles.

// fetch-user.js
async function fetchUserProfile(userId) {
  // Each await pauses execution until that line resolves
  const response  = await fetch(`/api/users/${userId}`);
  const user      = await response.json();
  const orders    = await fetch(`/api/orders?userId=${user.id}`);
  const orderData = await orders.json();

  return { user, orders: orderData };
}

// Usage — reads like synchronous code
const profile = await fetchUserProfile('user_42');
console.log(profile.user.name); // "Alice"

Compare this to the equivalent Promise chain — same logic, much harder to follow:

// fetch-user-promises.js
function fetchUserProfile(userId) {
  return fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(user => {
      return fetch(`/api/orders?userId=${user.id}`)
        .then(res => res.json())
        .then(orders => ({ user, orders })); // note: closure required for user
    });
}

Note the closure needed to keep user in scope — a common source of bugs. async/await eliminates this entirely.


Error Handling

Rejected Promises become throwable errors inside async functions, so try/catch handles both sync and async errors in the same block.

// error-handling.js
async function loadDashboard(userId) {
  try {
    const user  = await getUser(userId);        // may reject
    const perms = await getPermissions(user.id); // may reject

    if (!perms.includes('dashboard')) {
      throw new Error('Access denied'); // sync throw — same catch block
    }

    return await buildDashboard(user);

  } catch (err) {
    console.error('Dashboard load failed:', err.message);
    throw err; // re-throw so caller knows something failed
  }
}

// Output on permission error:
// Dashboard load failed: Access denied

Common mistake: Forgetting to await a Promise means the catch block never fires for that rejection. const data = fetchData() gives you a Promise, not the data. Always await or handle the returned Promise.


Parallel Execution with Promise.all

Sequential await calls run one at a time. If two operations don't depend on each other, run them in parallel using Promise.all:

// parallel-fetch.js

// Sequential — takes 600ms (200ms + 400ms)
async function sequentialLoad(userId) {
  const user     = await getUser(userId);     // 200ms
  const settings = await getSettings(userId); // 400ms
  return { user, settings };
}

// Parallel — takes ~400ms (max of the two)
async function parallelLoad(userId) {
  const [user, settings] = await Promise.all([
    getUser(userId),
    getSettings(userId),
  ]);
  return { user, settings };
}

// 33% faster — both requests fire simultaneously

Comparison: Promises vs async/await

Dimension Promises (.then) async/await
Readability Chaining is OK for simple flows; breaks down with branching logic Reads linearly — sequential async code looks synchronous
Error handling Requires .catch() at the end of the chain; easy to miss Standard try/catch — works for both sync and async errors
Debugging Stack traces can be shallow; callbacks may not show caller context Better stack traces; breakpoints work on await lines
Parallel execution Promise.all() built for this Use await Promise.all() — same underlying mechanism
Conditionals Nested .then() or explicit early returns required Plain if/else inside the function
Underlying runtime Identical — async/await compiles to Promises Identical — async/await compiles to Promises

Trade-offs

Strengths:

  • Reads like synchronous code

  • try/catch handles sync and async errors in one block

  • Easier debugging with meaningful stack traces

  • Loops and conditionals work naturally

Limitations:

  • Sequential awaits miss parallelism opportunities

  • Must be used inside an async function (except ES module top level)

  • Unhandled rejections can fail silently if .catch() is omitted on the outer Promise

  • Async functions always return Promises — callers must handle that

When NOT to use await sequentially: If you write await a(); await b(); await c() and none of those depend on each other, you've accidentally made parallel work sequential. Use Promise.all() when there's no data dependency between calls.


Conclusion

async/await doesn't change what JavaScript can do — it changes how async code looks and feels. The main win is that error handling, branching, and sequential logic all work the way you already know, without learning a new API.

The mental model: async marks a function as Promise-returning. await pauses that function until a Promise settles. Everything else follows from that.

Next step: explore Promise.allSettled() for cases where you want all results regardless of failures, and AbortController for cancellable async operations.


Further Reading

  1. MDN — async function — Authoritative reference with full spec details and edge cases

  2. javascript.info — Async/await — Thorough walkthrough with interactive examples

  3. V8 Blog — Faster async functions and promises — How the V8 engine optimizes async/await at the runtime level

  4. MDN — Using Promises — Understanding the underlying Promise model helps write better async code

  5. web.dev — JavaScript Promises: an introduction — Jake Archibald's foundational explainer on the Promise design