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)
- Read lessons in order —
README.md, then1.17.a→1.17.e. - Practice out loud — definition → example → pitfall.
- Pair with exercises —
1.17-Exercise-Questions.md. - Quick review —
1.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:
| Aspect | Callback | Promise |
|---|---|---|
| Control | Inverted — callee decides when/how to call you | You hold the Promise and attach handlers |
| Chaining | Requires nesting (callback hell) | .then() returns a new Promise — flat chains |
| Error handling | Each level checks err manually | Errors propagate to nearest .catch() |
| Composition | No built-in parallel/race patterns | Promise.all, Promise.race, etc. |
| Guarantee | Convention 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)placesfnin the macrotask queue.Promise.resolve().then(fn)placesfnin 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/setIntervalcallbacks- I/O callbacks
requestAnimationFrame(browser)- UI rendering
Microtask queue:
- Promise
.then()/.catch()/.finally()handlers queueMicrotask()callbacksMutationObservercallbacks
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:
| Aspect | Promise.all | Promise.allSettled |
|---|---|---|
| Resolves when | All promises fulfill | All promises settle (fulfill or reject) |
| Rejects when | Any promise rejects (fail-fast) | Never — always resolves |
| Result format | Array of fulfilled values | Array of {status, value} or {status, reason} objects |
| Use case | All 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:
console.log("D")— sync, prints D.foo()is called. Insidefoo:console.log("A")— sync, prints A.await bar()is reached.bar()is called synchronously:console.log("C")prints C.bar()returns a Promise (fulfilled withundefined).awaitsuspendsfoo— everything afterawait(console.log("B")) is scheduled as a microtask.- Execution returns to the call site.
console.log("E")— sync, prints E. - Call stack is now empty. Event loop checks microtask queue: the continuation of
fooruns →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
| # | Question | One-line answer |
|---|---|---|
| 1 | Does async function always return a Promise? | Yes — even if you return a plain value |
| 2 | Microtask or macrotask: setTimeout? | Macrotask |
| 3 | Microtask or macrotask: .then()? | Microtask |
| 4 | Promise.race — resolves when? | First promise to settle (fulfill or reject) |
| 5 | await in forEach works? | No — use for...of or Promise.all + map |
| 6 | Can a settled Promise change state? | No — immutable once settled |
| 7 | Promise.any rejects when? | All promises reject (AggregateError) |
| 8 | setInterval callback takes longer than interval? | Calls queue up / no gap between them |
| 9 | clearTimeout after timer fired? | No effect — harmless no-op |
| 10 | Error in async function without try...catch? | Returned Promise rejects |
← Back to 1.17 — Asynchronous Programming (README)