Practice Questions

1. What is asynchronous programming and why is it essential in JavaScript? In your answer, explain what would happen if JavaScript only supported synchronous execution.

Solution

Asynchronous programming lets long-running operations, such as network requests or file reads, run without blocking the main thread. Instead of waiting for a task to complete, the program starts the task, continues executing other code, and handles the result when it is ready.

It is essential because JavaScript is single-threaded. It has one call stack. Without async programming, a network request would freeze the entire application until it completes. In a browser, that means an unresponsive UI where the user cannot click, scroll, or type. In Node.js, it means no other requests can be served while one is being processed.

2. Using the error-first callback convention, write two functions: getUser(id, callback) that simulates fetching a user (reject if id <= 0), and getPosts(userId, callback) that simulates fetching posts. Use setTimeout to simulate the async delay. Then write the code that calls getUser and, on success, calls getPosts with the user’s id, logging the results.

Solution
function getUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid user ID"));
      return;
    }
    callback(null, { id, name: "Alice" });
  }, 500);
}

function getPosts(userId, callback) {
  setTimeout(() => {
    callback(null, [
      { id: 1, title: "Hello World" },
      { id: 2, title: "Async is fun" },
    ]);
  }, 500);
}

// Usage:
getUser(1, (err, user) => {
  if (err) {
    console.error(err.message);
    return;
  }
  getPosts(user.id, (err, posts) => {
    if (err) {
      console.error(err.message);
      return;
    }
    console.log(user.name, posts);
  });
});

The error-first convention means the first argument to the callback is the error (or null if successful), and subsequent arguments are the result data.

3. Rewrite your answer to Question 2 using Promises. Each function should return a Promise instead of accepting a callback. Chain the operations using .then() and handle errors with .catch().

Solution
function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid user ID"));
        return;
      }
      resolve({ id, name: "Alice" });
    }, 500);
  });
}

function getPosts(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "Hello World" },
        { id: 2, title: "Async is fun" },
      ]);
    }, 500);
  });
}

// Usage:
getUser(1)
  .then((user) => {
    return getPosts(user.id).then((posts) => ({ user, posts }));
  })
  .then(({ user, posts }) => {
    console.log(user.name, posts);
  })
  .catch((error) => {
    console.error(error.message);
  });

Each function now returns a Promise instead of accepting a callback. The nested callbacks are replaced with .then() chaining, and a single .catch() at the end handles errors from any step.

4. Rewrite your answer to Question 3 using async/await. Handle errors with try/catch.

Solution
// getUser and getPosts remain the same (they return Promises).

async function loadUserAndPosts(id) {
  try {
    const user = await getUser(id);
    const posts = await getPosts(user.id);
    console.log(user.name, posts);
  } catch (error) {
    console.error(error.message);
  }
}

loadUserAndPosts(1);

The getUser and getPosts functions do not change. They already return Promises. The difference is in how we consume them. With async/await, the code reads like synchronous code: each await pauses until the Promise settles, and standard try/catch handles errors.

5. What is “callback hell” and what problems does it cause? Looking at the three patterns you used in Questions 2–4, explain how each successive pattern improves on the previous one.

Solution

Callback hell (also called the “pyramid of doom”) occurs when multiple asynchronous operations depend on each other, leading to deeply nested callbacks:

getUser(id, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return handleError(err);
      // More nesting...
    });
  });
});

Problems: hard to read (rightward drift), repetitive error handling at every level, difficult to compose or reorder, and breaks normal control flow (return, throw, loops do not work across callbacks).

How each pattern improves on the last:

  1. Callbacks → Promises: Flattens nested callbacks into a .then() chain. Error handling is centralized with a single .catch() instead of checking if (err) at every level.
  2. Promises → Async/Await: Makes asynchronous code read like synchronous code. Uses standard try/catch for error handling and supports normal control flow like loops and conditionals.

6. What are the three states of a Promise? Once a Promise is fulfilled, can it later become rejected? Explain.

Solution

The three states are:

  1. Pending — the initial state; the operation has not completed yet.
  2. Fulfilled — the operation completed successfully, and the Promise has a result value.
  3. Rejected — the operation failed, and the Promise has a reason (error).

Once a Promise is settled (either fulfilled or rejected), its state cannot change. A fulfilled Promise cannot become rejected, and a rejected Promise cannot become fulfilled. That is deliberate. Once a Promise has settled, any code that uses it can count on the result staying the same.

7. Write an async function loadUser that fetches a user from /api/users/:id, handles HTTP errors (non-OK responses), and uses a finally block to log that the request is complete. Use try/catch/finally.

Solution
async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Failed to load user:", error.message);
    throw error; // Re-throw so callers know it failed
  } finally {
    console.log("User request complete");
  }
}

The try block contains the fetch and response checking. The catch block handles both network errors (fetch fails) and HTTP errors (non-OK status). The finally block runs regardless of success or failure, making it suitable for cleanup or logging.

8. The following async function runs three independent operations sequentially. Explain why this is slow and rewrite it to run the operations in parallel.

async function loadDashboard(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(userId);
  const notifications = await fetchNotifications(userId);
  return { user, posts, notifications };
}
Solution

The sequential version is slow because each await pauses execution until that operation completes before starting the next one. If each takes 1 second, the total time is ~3 seconds. Since the three operations are independent (none depends on the result of another), they can run concurrently:

async function loadDashboard(userId) {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
  ]);
  return { user, posts, notifications };
}

With Promise.all, all three requests start at the same time. The total time is roughly the duration of the slowest operation (~1 second), not the sum of all three.

9. What is the output of the following code? Explain the order and why.

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

console.log("D");
Solution

The output is:

A
D
C
B

Explanation:

  1. "A" — runs immediately on the call stack.
  2. setTimeout callback is sent to the Web API and queued in the callback queue (task queue) after the timer expires.
  3. Promise.resolve().then(...) callback is queued in the microtask queue.
  4. "D" — runs immediately on the call stack.
  5. The call stack is now empty. The event loop checks the microtask queue first, so "C" prints.
  6. The microtask queue is empty. The event loop then checks the callback queue, so "B" prints.

The key point: Promise callbacks (microtasks) always run before setTimeout callbacks (macrotasks) when the call stack is empty.

10. Briefly describe all four Promise combinator methods (Promise.all, Promise.allSettled, Promise.race, Promise.any). For each, state when it resolves, when it rejects, and give a one-sentence use case.

Solution
Method Resolves when Rejects when Use case
Promise.all All Promises fulfill Any single Promise rejects (fail-fast) Fetching multiple resources where you need all of them to proceed.
Promise.allSettled All Promises settle (fulfill or reject) Never rejects Loading multiple independent resources where partial failure is acceptable.
Promise.race First Promise settles (fulfill or reject) First Promise settles as a rejection Implementing a timeout by racing a request against a timer.
Promise.any First Promise fulfills All Promises reject (throws AggregateError) Requesting from multiple servers and using whichever responds first.