← Back to Tutorials Chapter 8

Asynchronous JavaScript

Asynchronous programming is essential for non-blocking operations like fetching data, reading files, or waiting for user input. JavaScript handles asynchrony through callbacks, promises, and async/await.

Synchronous vs Asynchronous

Synchronous code runs line by line, blocking further execution until each operation completes. Asynchronous code lets the program continue while waiting for slow operations:

// Synchronous — blocks until complete
console.log("Start");
const data = fetchDataSync(); // Blocks for 2 seconds
console.log(data);

// Asynchronous — does not block
console.log("Start");
fetchDataAsync((data) => {
  console.log(data); // Runs later
});
console.log("End"); // Runs immediately after "Start"

Callbacks

A callback is a function passed as an argument to another function, to be executed later:

function fetchUser(id, callback) {
  setTimeout(() => {
    callback({ id, name: "Alice" });
  }, 1000);
}

fetchUser(1, (user) => {
  console.log("User:", user.name);
});

console.log("Waiting for user...");

Callback Hell

Nesting callbacks creates deeply indented, hard-to-maintain code known as "callback hell":

fetchUser(1, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      fetchLikes(comments[0].id, (likes) => {
        console.log(likes);
        // Deep nesting continues...
      });
    });
  });
});
Problem: Callback hell makes code hard to read, debug, and maintain. Promises and async/await provide a much cleaner alternative.

Promises

A Promise represents a value that may be available now, later, or never. It has three states: pending, fulfilled, or rejected.

const promise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {
      resolve("Operation succeeded!");
    } else {
      reject("Operation failed!");
    }
  }, 1000);
});

promise
  .then((result) => console.log(result))
  .catch((error) => console.error(error))
  .finally(() => console.log("Done"));

Chaining Promises

fetchUser(1)
  .then((user) => fetchPosts(user.id))
  .then((posts) => fetchComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((error) => console.error("Error:", error));

Promise.all

Wait for all promises to resolve (or any to reject):

const p1 = fetchUser(1);
const p2 = fetchUser(2);
const p3 = fetchUser(3);

Promise.all([p1, p2, p3])
  .then((users) => console.log("All users:", users))
  .catch((err) => console.error("One failed:", err));

Promise.race

Resolve or reject as soon as the first promise settles:

const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error("Timeout")), 5000)
);

const data = fetch("https://api.example.com/data");
Promise.race([data, timeout])
  .then((result) => console.log(result))
  .catch((err) => console.error(err));
Additional Promise Combinators: Promise.allSettled waits for all promises regardless of rejection, returning their outcomes. Promise.any resolves when the first promise fulfills (rejects only if all reject).

async / await

Introduced in ES2017, async/await makes asynchronous code look synchronous:

async function loadUserData(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (error) {
    console.error("Failed to load user data:", error);
    throw error;
  }
}

// Usage
loadUserData(1)
  .then((comments) => console.log(comments))
  .catch((err) => console.error(err));

Async functions always return a Promise

async function greet(name) {
  return `Hello, ${name}!`;
}

greet("Alice").then(console.log); // "Hello, Alice!"

async function example() {
  // Parallel execution with await
  const [user1, user2] = await Promise.all([
    fetchUser(1),
    fetchUser(2)
  ]);
  console.log(user1, user2);
}

Error Handling in Async Functions

Always wrap await calls in try/catch blocks to handle rejections gracefully:

async function safeFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error(`Fetch failed for ${url}:`, error.message);
    return null; // Graceful fallback
  }
}

The Event Loop

The event loop is JavaScript's concurrency model. It continuously checks the call stack and callback queue:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve()
  .then(() => console.log("3"));

console.log("4");
// Output: 1, 4, 3, 2
// Microtask (Promise) runs before the next task (setTimeout)
JavaScript event loop and async flow
Key Concept: Microtasks (Promise callbacks, queueMicrotask) execute before the next macrotask (setTimeout, setInterval, I/O). This means promises resolve before timers, even when the timer delay is 0ms.
Exercise: Rewrite the following callback-hell code using async/await with try/catch: simulate fetching a user (1s), then their orders (1s), then order details (1s) — each step depends on the previous result. Use setTimeout wrapped in a Promise to simulate each async operation. Also add a timeout race using Promise.race that rejects if the whole chain takes longer than 4 seconds.