Episode 1 — Fundamentals / 1.17 — Asynchronous Programming JavaScript

Interview Questions: Asynchronous Programming in JavaScript

Model answers for the event loop, callbacks vs Promises, async/await, microtask vs macrotask, Promise combinators, timer accuracy, and error handling patterns.

How to use this material (instructions)

  1. Read lessons in orderREADME.md, then 1.17.a1.17.e.
  2. Practice out loud — definition → example → pitfall.
  3. Pair with exercises1.17-Exercise-Questions.md.
  4. Quick review1.17-Quick-Revision.md.

Beginner (Q1–Q4)

Q1. What is the event loop in JavaScript?

Why interviewers ask: Confirms you understand how JS handles async despite being single-threaded.

Model answer:

The event loop is the mechanism that coordinates JavaScript's execution on a single thread. When JS encounters an async operation (setTimeout, fetch, etc.), it delegates the waiting to the browser's Web APIs. The main thread continues running other code. When the async work completes, a callback is placed in a queue. The event loop checks: if the call stack is empty, it first drains all microtasks (Promise callbacks, queueMicrotask), then picks the oldest macrotask (setTimeout, setInterval callbacks) and pushes it onto the call stack. This cycle repeats indefinitely, allowing JS to handle concurrency without multithreading.


Q2. What is the difference between a callback and a Promise?

Why interviewers ask: Tests understanding of JavaScript's async evolution.

Model answer:

A callback is a function passed to another function for later invocation — it is the simplest async pattern. A Promise is an object representing a future value, with three states: pending, fulfilled, rejected. Key differences:

AspectCallbackPromise
ControlInverted — callee decides when/how to call youYou hold the Promise and attach handlers
ChainingRequires nesting (callback hell).then() returns a new Promise — flat chains
Error handlingEach level checks err manuallyErrors propagate to nearest .catch()
CompositionNo built-in parallel/race patternsPromise.all, Promise.race, etc.
GuaranteeConvention only (error-first)Spec-enforced: settles once, immutable

Promises solve the inversion of control and composability problems that callbacks have.


Q3. What does async/await do?

Why interviewers ask: Daily usage — verifies you know it is syntactic sugar over Promises, not a new mechanism.

Model answer:

async/await is syntactic sugar over Promises introduced in ES2017. An async function always returns a Promise. The await keyword pauses execution of that function (not the whole thread) until the awaited Promise settles. This makes async code read like synchronous code while maintaining non-blocking behavior.

// Promise chain
fetch("/api/data").then(res => res.json()).then(data => console.log(data));

// Equivalent with async/await
async function load() {
  const res = await fetch("/api/data");
  const data = await res.json();
  console.log(data);
}

Errors are handled with try...catch instead of .catch(). Under the hood, await uses microtasks — the same Promise machinery.


Q4. What is the difference between setTimeout(fn, 0) and Promise.resolve().then(fn)?

Why interviewers ask: Tests microtask vs macrotask priority understanding.

Model answer:

Both schedule fn to run asynchronously, but in different queues:

  • setTimeout(fn, 0) places fn in the macrotask queue.
  • Promise.resolve().then(fn) places fn in the microtask queue.

Microtasks always run before macrotasks when the call stack is empty. So:

setTimeout(() => console.log("macro"), 0);
Promise.resolve().then(() => console.log("micro"));
console.log("sync");
// Output: sync, micro, macro

This means Promise callbacks get priority over timer callbacks, which is important for understanding execution order in complex async code.


Intermediate (Q5–Q8)

Q5. Explain microtask queue vs macrotask queue with examples.

Why interviewers ask: Deep event loop understanding — critical for debugging async timing issues.

Model answer:

The JavaScript runtime uses two types of task queues:

Macrotask queue (task queue):

  • setTimeout / setInterval callbacks
  • I/O callbacks
  • requestAnimationFrame (browser)
  • UI rendering

Microtask queue:

  • Promise .then() / .catch() / .finally() handlers
  • queueMicrotask() callbacks
  • MutationObserver callbacks

Execution rule: After each macrotask (or after the initial script), the engine drains all microtasks before running the next macrotask. Microtasks can enqueue more microtasks, and they all run before any macrotask.

console.log("1");                                    // sync
setTimeout(() => console.log("2"), 0);               // macrotask
Promise.resolve().then(() => {
  console.log("3");                                  // microtask
  Promise.resolve().then(() => console.log("4"));    // nested microtask
});
console.log("5");                                    // sync

// Output: 1, 5, 3, 4, 2
// All microtasks (including nested) complete before the setTimeout macrotask

Q6. What is callback hell and how do Promises solve it?

Why interviewers ask: Practical problem-solving — shows you understand async code quality.

Model answer:

Callback hell (pyramid of doom) occurs when sequential async operations require nested callbacks:

getUser(id, (err, user) => {
  getPosts(user.id, (err, posts) => {
    getComments(posts[0].id, (err, comments) => {
      // deeply nested, hard to read and maintain
    });
  });
});

Problems: poor readability, duplicated error handling at every level, inversion of control, difficult to add/remove steps.

Promises solve this through chaining:

getUser(id)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => console.error(err));  // single error handler

The chain is flat, each step returns a Promise, errors propagate to a single .catch(), and there is no inversion of control because you hold the Promise.


Q7. What is the difference between Promise.all and Promise.allSettled?

Why interviewers ask: Common real-world need — handling multiple concurrent async operations.

Model answer:

AspectPromise.allPromise.allSettled
Resolves whenAll promises fulfillAll promises settle (fulfill or reject)
Rejects whenAny promise rejects (fail-fast)Never — always resolves
Result formatArray of fulfilled valuesArray of {status, value} or {status, reason} objects
Use caseAll must succeed (e.g., loading required data)Need results regardless of individual failures
// Promise.all — rejects if ANY fails
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);

// Promise.allSettled — always gets all results
const results = await Promise.allSettled([fetchUsers(), fetchPosts()]);
results.forEach(r => {
  if (r.status === "fulfilled") console.log("OK:", r.value);
  else console.log("Failed:", r.reason);
});

Use Promise.all when every result is required. Use Promise.allSettled when you want to attempt everything and handle failures individually.


Q8. How do you handle errors in async/await?

Why interviewers ask: Error handling is where most async bugs live.

Model answer:

Three approaches, each with trade-offs:

1. try...catch (most common):

async function loadData() {
  try {
    const res = await fetch("/api/data");
    if (!res.ok) throw new Error("HTTP " + res.status);
    return await res.json();
  } catch (err) {
    console.error("Failed:", err.message);
    return null; // fallback
  } finally {
    hideSpinner(); // cleanup regardless
  }
}

2. .catch() on the returned Promise (caller-side):

async function loadData() {
  const res = await fetch("/api/data");
  return res.json();
}

loadData().catch(err => console.error(err));

3. Per-await error handling (when different errors need different treatment):

async function complex() {
  const user = await fetchUser().catch(() => null);
  if (!user) return showLoginScreen();

  const posts = await fetchPosts(user.id).catch(() => []);
  renderPosts(posts); // works even if posts fetch failed (empty array)
}

Golden rule: Every async function call should either be inside a try...catch or have .catch() attached by the caller. Unhandled rejections crash Node.js processes and pollute the browser console.


Advanced (Q9–Q11)

Q9. Can microtasks starve macrotasks? Explain with an example.

Why interviewers ask: Tests deep event loop knowledge and potential performance pitfalls.

Model answer:

Yes. Because the event loop drains all microtasks before running any macrotask, a microtask that continuously enqueues more microtasks will starve macrotasks indefinitely — including setTimeout callbacks and UI rendering.

// WARNING: This can freeze the browser tab
function starveMacrotasks() {
  Promise.resolve().then(starveMacrotasks);
}

setTimeout(() => console.log("I will never run"), 0);
starveMacrotasks();
// The setTimeout callback never fires because the microtask queue is never empty

Real-world implications:

  • The browser cannot repaint (UI freezes)
  • Timer callbacks never fire
  • Event handlers never execute

This is why long-running async processing should periodically yield to the macrotask queue (e.g., using setTimeout(fn, 0) to break up work).


Q10. Explain the order of execution for this code and why.

async function foo() {
  console.log("A");
  await bar();
  console.log("B");
}

async function bar() {
  console.log("C");
}

console.log("D");
foo();
console.log("E");

Why interviewers ask: Tests precise understanding of how await interacts with the event loop.

Model answer:

Output: D, A, C, E, B

Step-by-step:

  1. console.log("D") — sync, prints D.
  2. foo() is called. Inside foo: console.log("A") — sync, prints A.
  3. await bar() is reached. bar() is called synchronously: console.log("C") prints C. bar() returns a Promise (fulfilled with undefined).
  4. await suspends foo — everything after await (console.log("B")) is scheduled as a microtask.
  5. Execution returns to the call site. console.log("E") — sync, prints E.
  6. Call stack is now empty. Event loop checks microtask queue: the continuation of foo runs → console.log("B") prints B.

Key insight: await does not pause the entire program — it suspends only the current async function and returns control to the caller. The code after await behaves like a .then() callback (microtask).


Q11. What happens if you await a non-Promise value?

Why interviewers ask: Edge case knowledge — shows thoroughness.

Model answer:

If you await a non-Promise value, JavaScript wraps it in Promise.resolve() first. The value is returned directly, but there is still a microtask yield — execution pauses and resumes in the microtask queue.

async function example() {
  const x = await 42;       // equivalent to: await Promise.resolve(42)
  console.log(x);           // 42
  const y = await "hello";  // await Promise.resolve("hello")
  console.log(y);           // "hello"
}

console.log("before");
example();
console.log("after");

// Output: before, after, 42, hello
// "42" prints after "after" because await still yields to the microtask queue

This means await always introduces an asynchronous boundary, even with synchronous values. This can matter in performance-sensitive code.


Quick-fire

#QuestionOne-line answer
1Does async function always return a Promise?Yes — even if you return a plain value
2Microtask or macrotask: setTimeout?Macrotask
3Microtask or macrotask: .then()?Microtask
4Promise.race — resolves when?First promise to settle (fulfill or reject)
5await in forEach works?No — use for...of or Promise.all + map
6Can a settled Promise change state?No — immutable once settled
7Promise.any rejects when?All promises reject (AggregateError)
8setInterval callback takes longer than interval?Calls queue up / no gap between them
9clearTimeout after timer fired?No effect — harmless no-op
10Error in async function without try...catch?Returned Promise rejects

← Back to 1.17 — Asynchronous Programming (README)