<< Home

Chapter 15: Asynchronous JavaScript

JavaScript is single-threaded, meaning it executes one operation at a time in sequence. This design keeps the language simple and predictable, but it also means that long-running tasks (such as network requests, timers, or file operations) could otherwise block execution and make applications unresponsive.

To overcome this, JavaScript introduced asynchronous programming models that let code continue running while waiting for other operations to complete. These include callbacks, promises, and the async and await keywords, which together form the foundation of modern non-blocking JavaScript.

This chapter explores the evolution of asynchronous handling in JavaScript from early callback functions and their inversion-of-control issues, through to the elegance and clarity provided by promises and async/await. You will also learn about handling errors in asynchronous flows, and how to manage concurrent tasks with utilities such as Promise.all() and Promise.race().

Callbacks and the inversion problem

Before promises and async/await, the primary way to handle asynchronous operations in JavaScript was through callbacks. A callback is simply a function passed as an argument to another function, to be executed after a task completes. This allows you to delay execution of certain code until the required data or event is ready.

function getData(callback) {
  setTimeout(() => {
    callback("Data received");
  }, 1000);
}

getData((result) => {
  console.log(result);
});

In this example, the getData() function simulates an asynchronous operation using setTimeout(). It takes a callback function that runs once the simulated delay is over. This pattern became the standard way to manage non-blocking behavior in JavaScript for many years.

💡 A callback is just a function reference, not special syntax. You can pass any function to another as a parameter and invoke it later, even synchronously.

However, as applications grew in complexity, callbacks often led to a structure known as “callback hell,” where nested asynchronous operations became deeply indented and hard to manage. Each step depended on the previous one, resulting in tangled, hard-to-read code.

doStep1((result1) => {
  doStep2(result1, (result2) => {
    doStep3(result2, (result3) => {
      doStep4(result3, (result4) => {
        console.log("All done");
      });
    });
  });
});

This inversion of control, where you hand over execution flow to multiple callbacks, made debugging, error handling, and reasoning about program order extremely difficult. The more dependent tasks there were, the worse the structure became.

⚠️ When callbacks are nested within other callbacks, even simple logic can become fragile. A small mistake in order, scope, or error handling can break the entire chain.

The limitations of this model motivated the creation of promises, which offer a cleaner, more predictable way to manage asynchronous behavior without the deep nesting that callbacks require.

Promises and chaining

Promises were introduced to make asynchronous code more structured and readable. A Promise represents a value that will be available now, later, or never, depending on whether the asynchronous operation succeeds or fails. It acts as a placeholder for a future result and provides methods to handle completion or failure in a clean, sequential manner.

const dataPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Data loaded");
  }, 1000);
});

dataPromise.then((result) => {
  console.log(result);
});

Here, new Promise() takes a function with two parameters: resolve (to indicate success) and reject (to indicate failure). When resolve() is called, any .then() handler attached to the promise is executed. This allows asynchronous tasks to be expressed in a linear, chainable fashion.

💡 A promise can be in one of three states: pending, fulfilled, or rejected. Once settled (fulfilled or rejected), it cannot change state again.

Promises also allow chaining through multiple .then() calls. Each .then() returns a new promise, so you can process results step by step in a clear, top-down manner.

fetch("data.json")
  .then((response) => response.json())
  .then((data) => {
    console.log("Parsed data:", data);
  })
  .catch((error) => {
    console.error("Error:", error);
  });

Each .then() receives the resolved value from the previous step. If any error occurs, the .catch() at the end handles it, no matter where the rejection happened in the chain. This makes error propagation simpler and avoids multiple nested callback levels.

⚠️ If a .then() handler throws an error or returns a rejected promise, the chain skips directly to the next .catch(). Always include .catch() when chaining promises to prevent unhandled rejections.

By structuring asynchronous operations as promise chains, code becomes easier to read and maintain. Instead of deeply nested callbacks, each asynchronous step forms part of a logical sequence, setting the stage for the cleaner async and await syntax that builds on this foundation.

async and await

The async and await keywords provide a modern, elegant way to work with promises in JavaScript. They let you write asynchronous code that looks and behaves more like synchronous code, making it easier to follow the program’s logic and handle results or errors.

Declaring a function as async automatically makes it return a promise. Inside that function, the await keyword can be used to pause execution until a promise resolves, then resume with the resolved value.

async function loadData() {
  const response = await fetch("data.json");
  const data = await response.json();
  console.log("Data:", data);
}

loadData();

In this example, the function waits for the fetch request to complete and then for the JSON to be parsed, without blocking the main thread. The code appears linear, but each await allows other operations to run while waiting for the asynchronous task to finish.

💡 The await keyword can only be used inside functions declared with async. If you try to use it elsewhere, a syntax error will occur.

Because async functions return promises, you can still chain them or combine them with other asynchronous patterns. This makes it simple to mix older and newer styles of asynchronous handling in the same codebase.

async function getUserData() {
  try {
    const res = await fetch("/user");
    const user = await res.json();
    return user;
  } catch (err) {
    console.error("Failed to load user:", err);
  }
}

getUserData().then((u) => console.log("User:", u));

Here, errors are handled using standard try and catch syntax, just as in synchronous code. Any error thrown inside an async function rejects the returned promise, which can then be caught with .catch() if desired.

⚠️ When using await with multiple independent asynchronous calls, placing them sequentially can make the code run slower than necessary. Use Promise.all() to execute them concurrently when they do not depend on each other.

The combination of async and await represents the most readable and natural approach to asynchronous programming in JavaScript. It combines the clarity of synchronous code with the non-blocking behavior of promises, forming the backbone of most modern JavaScript applications.

Error handling with promises

When working with asynchronous code, handling errors effectively is just as important as managing results. Promises provide a built-in mechanism for dealing with both successful and failed outcomes, making error propagation predictable and easy to control.

A promise can either resolve successfully or reject with an error. You can handle both cases by chaining .then() and .catch() methods, or by using async and await with try/catch blocks.

fetch("missing.json")
  .then((response) => {
    if (!response.ok) {
      throw new Error("Network error");
    }
    return response.json();
  })
  .then((data) => {
    console.log("Data:", data);
  })
  .catch((error) => {
    console.error("Error caught:", error.message);
  });

Here, if the file cannot be fetched or if response.ok is false, the throw statement rejects the promise. The .catch() at the end of the chain then receives the error, ensuring that all asynchronous issues are handled in one consistent place.

💡 Any error thrown inside a .then() callback automatically rejects the promise, so you do not need to wrap every operation in try/catch when using promise chains.

When using async and await, the same principle applies, but you can handle errors using familiar synchronous syntax:

async function loadData() {
  try {
    const response = await fetch("data.json");
    const data = await response.json();
    console.log("Loaded:", data);
  } catch (error) {
    console.error("Failed:", error.message);
  }
}

loadData();

If any awaited promise rejects, control jumps immediately to the nearest catch block. This makes asynchronous error handling as straightforward as in regular synchronous code.

⚠️ Unhandled promise rejections can terminate a Node.js process or show warnings in browsers. Always include .catch() or use try/catch around awaited code to prevent silent failures.

By treating rejections like exceptions, promises and async/await provide a clear and reliable way to handle errors in asynchronous programs. This consistency allows developers to build robust systems without resorting to deeply nested callback logic.

Concurrency with Promise.all and race

While await makes asynchronous code easier to read, using it sequentially can cause unnecessary delays when multiple tasks can run at the same time. JavaScript provides built-in utilities such as Promise.all() and Promise.race() to handle concurrency efficiently, allowing several asynchronous operations to proceed together.

Promise.all() takes an array (or iterable) of promises and returns a single promise that resolves when all of them have fulfilled, or rejects immediately if any of them fail.

const p1 = fetch("/user");
const p2 = fetch("/posts");
const p3 = fetch("/comments");

Promise.all([p1, p2, p3])
  .then(async ([userRes, postRes, commentRes]) => {
    const [user, posts, comments] = await Promise.all([
      userRes.json(),
      postRes.json(),
      commentRes.json()
    ]);
    console.log("User:", user);
    console.log("Posts:", posts);
    console.log("Comments:", comments);
  })
  .catch((err) => {
    console.error("One or more requests failed:", err);
  });

In this example, all three fetch requests start at the same time, and the program waits for all to complete before proceeding. This reduces total waiting time compared with awaiting them one by one.

💡 When using Promise.all(), if any promise rejects, the entire operation fails immediately. If you want all results regardless of success or failure, use Promise.allSettled() instead.

Promise.race() works differently. It resolves or rejects as soon as any of the supplied promises settles, returning the value or error from that first-completed promise. This can be useful for implementing timeouts or picking the fastest response among several sources.

const fetchWithTimeout = Promise.race([
  fetch("/slow-endpoint"),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout after 2s")), 2000)
  )
]);

fetchWithTimeout
  .then((res) => console.log("Response received:", res))
  .catch((err) => console.error(err.message));

Here, whichever operation finishes first (either the network request or the timeout) determines the outcome of the race. This technique is especially helpful for improving responsiveness in web applications.

⚠️ Because Promise.race() settles after the first result, it does not cancel other promises still running. Those operations will continue in the background unless explicitly aborted.

Using Promise.all() and Promise.race() wisely lets you balance concurrency, reliability, and speed. Together with async and await, they complete the core set of tools for managing asynchronous execution in modern JavaScript.