Event Loop and Asynchronous JavaScript
Understand how JavaScript achieves concurrency. Study call stack, callback queue, microtask queue, Web APIs, and the event loop cycle.
Downloaded from PrepIQ (https://prepiq.online)
Q1. What is the call stack in JavaScript and how does it relate to being single-threaded?
The call stack is a data structure that tracks function calls - each time a function is invoked, a frame is pushed on top; when it returns, the frame is popped off. JavaScript has exactly one call stack, meaning it can only execute one piece of code at a time.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n); // multiply's frame pushed on top of square's
}
function printSquare(n) {
const result = square(n); // square's frame pushed on top
console.log(result);
}
printSquare(5);
// Call stack grows: printSquare -> square -> multiply
// Then unwinds as each function returns: multiply pops, square pops, printSquare pops
// A long-running synchronous function BLOCKS the entire stack
function blockingLoop() {
const start = Date.now();
while (Date.now() - start < 3000) {
// busy-wait for 3 seconds - nothing else can run, not even UI updates
}
}
console.log('Before');
blockingLoop(); // freezes the page for 3 seconds
console.log('After'); // only runs after blockingLoop fully completes
// Stack overflow - too many nested calls exceed the stack's size limit
function recurse() {
return recurse(); // no base case
}
// recurse(); // RangeError: Maximum call stack size exceeded
// This single-threaded, single-stack nature is WHY async operations
// (setTimeout, fetch, etc.) are handled OFF the call stack via Web APIs,
// only being pushed back onto the stack once they're ready to run
Q2. What are Web APIs and how do they enable asynchronous behavior in JavaScript?
Web APIs (like setTimeout, fetch, DOM events) are provided by the browser (or Node.js runtime), not the JavaScript engine itself. They let time-consuming operations run outside the single call stack, without blocking execution.
console.log('1: Start');
// setTimeout is a Web API, NOT part of core JavaScript
setTimeout(() => {
console.log('3: Timeout callback');
}, 2000);
console.log('2: End');
// Output order: 1: Start, 2: End, 3: Timeout callback (after 2s)
// The setTimeout call itself returns immediately - the TIMER runs in the
// browser's Web API environment, separate from the JS call stack
// How it works step by step:
// 1. setTimeout() is called, JS hands off the timer + callback to the Web API
// 2. JS immediately continues executing the NEXT line (console.log('2: End'))
// 3. The Web API environment counts down the 2000ms independently
// 4. When the timer expires, the callback is placed in the CALLBACK QUEUE
// 5. The event loop moves it to the call stack ONLY when the stack is empty
// Common Web APIs that enable async behavior:
// - setTimeout / setInterval (timers)
// - fetch (network requests)
// - addEventListener (DOM events)
// - XMLHttpRequest
// - Geolocation, requestAnimationFrame, etc.
// Fetch example - the actual network request happens outside the call stack
console.log('Fetching...');
fetch('/api/data')
.then(response => response.json())
.then(data => console.log('Data received:', data));
console.log('This logs before fetch resolves');
// The browser's networking stack handles the request in the background
Q3. What is the difference between macrotasks and microtasks in the event loop?
| Queue type | Examples | Priority |
|---|---|---|
| Microtask queue | Promise .then/.catch/.finally, queueMicrotask, MutationObserver | Higher - fully drained before next macrotask |
| Macrotask (task) queue | setTimeout, setInterval, setImmediate, I/O, UI rendering | Lower - one processed per event loop cycle |
console.log('1: Sync');
setTimeout(() => console.log('2: Macrotask (setTimeout)'), 0);
Promise.resolve()
.then(() => console.log('3: Microtask A'))
.then(() => console.log('4: Microtask B'));
queueMicrotask(() => console.log('5: Microtask C'));
console.log('6: Sync');
// Output:
// 1: Sync
// 6: Sync
// 3: Microtask A
// 5: Microtask C
// 4: Microtask B
// 2: Macrotask (setTimeout)
// Explanation: all sync code first, THEN the entire microtask queue
// is drained (including newly added microtasks from within microtasks),
// and ONLY THEN does the event loop move to the next macrotask1. Run all synchronous code → 2. Drain ENTIRE microtask queue (even new ones added during draining) → 3. Process ONE macrotask → 4. Repeat from step 2
// A microtask that schedules more microtasks still runs before any macrotask
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => {
console.log('microtask 1');
Promise.resolve().then(() => console.log('microtask 2 (nested)'));
});
// Output: microtask 1, microtask 2 (nested), macrotask
Q4. How does the event loop actually work step by step?
The event loop is the mechanism that continuously checks whether the call stack is empty, and if so, moves queued callbacks (microtasks first, then macrotasks) onto the stack for execution.
console.log('Script start');
setTimeout(() => {
console.log('setTimeout callback');
}, 0);
fetch('/api/data').then(() => {
console.log('Fetch resolved');
});
console.log('Script end');
// Step-by-step trace:
// 1. 'Script start' logged (sync, runs on call stack immediately)
// 2. setTimeout registers its callback with the Web API, timer starts (0ms)
// 3. fetch() starts a network request via Web API, returns a pending Promise
// 4. 'Script end' logged (sync)
// 5. Call stack is now empty - script's synchronous execution finished
// 6. Event loop checks: any microtasks pending? None yet from fetch (still pending)
// 7. Event loop checks macrotask queue: setTimeout's callback is ready (0ms elapsed)
// 8. setTimeout callback pushed to call stack, 'setTimeout callback' logged
// 9. Later, when fetch's network response arrives, the resolved Promise's
// .then() callback is queued as a MICROTASK
// 10. Event loop picks it up (microtasks have priority over remaining macrotasks)
// and 'Fetch resolved' is loggedwhile (true) {
if (callStack.isEmpty()) {
while (microtaskQueue.hasTasks()) runNextMicrotask();
if (macrotaskQueue.hasTasks()) runNextMacrotask();
render(); // browser may repaint here
}
}
Q5. Why does setTimeout(fn, 0) not run immediately?
Even with a 0ms delay, setTimeout's callback is placed in the macrotask queue and must wait for the current synchronous code AND the entire microtask queue to finish before it can run.
console.log('1: First');
setTimeout(() => {
console.log('3: setTimeout (0ms)');
}, 0);
console.log('2: Second');
// Output: 1: First, 2: Second, 3: setTimeout (0ms)
// Even at 0ms delay, the callback ALWAYS runs after all sync code
// setTimeout(fn, 0) is also queued AFTER any pending microtasks
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('sync');
// Output: sync, microtask, macrotask - microtask ALWAYS wins even against 0ms timeout
// Additional browser detail: minimum delay clamping
// Nested setTimeout calls (deeper than a certain level) get clamped to a minimum of 4ms
function nestedTimeouts(count) {
if (count > 5) return;
setTimeout(() => {
console.log(`Nested call ${count}`);
nestedTimeouts(count + 1);
}, 0); // browsers may enforce a minimum ~4ms after nesting depth 5
}
// Practical use of setTimeout(fn, 0): deferring work until after
// the current synchronous execution and rendering finishes
function updateUI() {
document.body.style.background = 'blue'; // change happens
setTimeout(() => {
console.log('UI change should be painted by now');
}, 0);
}
// This is why setTimeout(fn, 0) is sometimes used as a way to "yield"
// control back to the browser/event loop before continuing heavy work
Q6. How does async/await interact with the event loop compared to raw Promises?
async/await doesn't change how the event loop fundamentally works - it's still built on Promises and microtasks. However, await pauses the async function's execution at that point, letting other code run in the meantime.
console.log('1: Start');
async function asyncFunc() {
console.log('2: Inside async function');
await null; // even awaiting a non-promise value schedules a microtask
console.log('4: After await'); // this runs as a MICROTASK
}
asyncFunc();
console.log('3: After calling asyncFunc');
// Output: 1: Start, 2: Inside async function, 3: After calling asyncFunc, 4: After await
// Explanation: code BEFORE the first await runs synchronously.
// The await pauses the function and schedules the REST of it as a microtask,
// allowing the calling code (line 3) to continue first
// Equivalent using raw Promises to show they behave the same way
function promiseFunc() {
console.log('2: Inside promise function');
return Promise.resolve(null).then(() => {
console.log('4: After then'); // microtask, same timing as await
});
}
promiseFunc();
console.log('3: After calling promiseFunc');
// Multiple awaits each create a new microtask checkpoint
async function multiStep() {
console.log('A');
await null;
console.log('B'); // microtask 1
await null;
console.log('C'); // microtask 2, queued after B resolves
}
multiStep();
console.log('D');
// Output: A, D, B, C - each await yields control back to the event loop once
Q7. What is the difference between setTimeout and setInterval, and how do you clear them?
| Function | Behavior | Clear with |
|---|---|---|
| setTimeout | Runs the callback ONCE after the delay | clearTimeout(id) |
| setInterval | Runs the callback REPEATEDLY at the given interval | clearInterval(id) |
// setTimeout - runs once
const timeoutId = setTimeout(() => {
console.log('Runs once after 1 second');
}, 1000);
// Cancelling before it fires
clearTimeout(timeoutId); // callback never runs
// setInterval - runs repeatedly
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Tick ${count}`);
if (count === 5) {
clearInterval(intervalId); // MUST clear manually or it runs forever
}
}, 1000);
// Common bug: forgetting to clear an interval causes it to keep firing
// even after the component/page logic that needed it is gone (memory leak)
function startPolling() {
return setInterval(() => {
console.log('Polling...');
}, 5000);
}
const pollId = startPolling();
// Later, when polling is no longer needed:
clearInterval(pollId);
// setInterval does NOT guarantee exact timing - if the callback takes
// longer than the interval, executions can overlap or queue up
// Modern alternative: recursive setTimeout for more predictable timing
function reliablePoll() {
setTimeout(() => {
console.log('Polling...');
reliablePoll(); // schedules the NEXT call only after this one finishes
}, 5000);
}
reliablePoll(); // avoids overlapping executions that setInterval can cause
Q8. How does JavaScript handle concurrency without multiple threads?
JavaScript achieves concurrency through non-blocking I/O and the event loop, rather than through true parallel execution on multiple threads. Long-running work is delegated elsewhere while JS itself stays single-threaded.
JS engine (single thread) → delegates I/O-bound work (timers, network, file access) to the browser/Node's C++ APIs or a thread pool → those run truly in parallel, outside JS → results come back as callbacks/Promises via the event loop
// This looks like it's doing multiple things 'at once', but JS itself
// never runs two lines of YOUR code simultaneously
console.log('Start');
setTimeout(() => console.log('Timer done'), 1000); // delegated to browser timer thread
fetch('/api/data').then(() => console.log('Fetch done')); // delegated to network stack
fs.readFile('file.txt', () => console.log('File read done')); // Node: delegated to libuv thread pool
console.log('End');
// 'Start' and 'End' run synchronously first; the three async operations
// run truly concurrently OUTSIDE the JS thread, and their callbacks
// are queued back into JS one at a time as each completes
// CPU-bound work does NOT benefit from this model - it still blocks
function heavyComputation() {
let result = 0;
for (let i = 0; i < 1e9; i++) result += i; // pure CPU work, blocks the thread
return result;
}
// heavyComputation() would freeze everything - no Web API can help here
// since there's no I/O to delegate, it's pure computation
// For true parallelism with CPU-bound work, use Web Workers (browser)
// or worker_threads (Node.js) - these run on SEPARATE threads entirely
const worker = new Worker('heavy-task.js');
worker.postMessage('start');
worker.onmessage = (e) => console.log('Result from worker:', e.data);
// Main thread stays responsive while the worker computes in parallel
Q9. What is 'starvation' in the event loop and how can microtasks cause it?
Since the microtask queue must be FULLY drained before the event loop moves to the next macrotask, a microtask that keeps scheduling more microtasks can starve macrotasks (like rendering or timers) indefinitely.
// DANGEROUS - infinite microtask loop starves everything else
function infiniteMicrotasks() {
Promise.resolve().then(() => {
console.log('microtask running...');
infiniteMicrotasks(); // schedules ANOTHER microtask before this one finishes
});
}
// infiniteMicrotasks(); // this would freeze the page - setTimeout, rendering,
// and even user input would NEVER get a chance to run, since the event loop
// can't move past 'drain the microtask queue' step
// setTimeout scheduled alongside this would NEVER fire:
setTimeout(() => console.log('This never runs if microtasks starve the loop'), 0);
// SAFER pattern - use setTimeout/macrotask to yield control periodically
function safeRecursiveWork(remaining) {
if (remaining <= 0) return;
console.log(`Processing... ${remaining} left`);
setTimeout(() => safeRecursiveWork(remaining - 1), 0);
// setTimeout yields to the macrotask queue, allowing rendering and
// other events to run BETWEEN each step, unlike a microtask loop
}
safeRecursiveWork(1000000); // page stays responsive throughout
// Practical guideline: use Promise chains/microtasks for logically
// connected async steps, but for long-running iterative work, periodically
// yield with setTimeout(fn, 0) or requestIdleCallback to keep the UI responsive
Q10. How would you predict the output order of a code snippet mixing sync code, setTimeout, and Promises?
Predicting execution order requires classifying every line into one of three buckets: synchronous, microtask, or macrotask - then applying the priority rule: all sync first, then all microtasks, then one macrotask at a time.
console.log('1'); // SYNC
setTimeout(() => console.log('2'), 0); // MACROTASK
Promise.resolve().then(() => console.log('3')); // MICROTASK
console.log('4'); // SYNC
async function asyncFn() {
console.log('5'); // SYNC (runs immediately when asyncFn() is called)
await null;
console.log('6'); // MICROTASK (after the await)
}
asyncFn();
Promise.resolve().then(() => console.log('7')); // MICROTASK
console.log('8'); // SYNC
// STEP 1 - classify each log call:
// SYNC: 1, 4, 5, 8 (run immediately, in written order)
// MICROTASK (queued in order added): 3, 6, 7
// MACROTASK: 2
// STEP 2 - trace execution:
// All sync code runs top to bottom first: 1, 4, 5, 8
// (note: 5 runs synchronously because code BEFORE the first await in an
// async function executes immediately when the function is called)
// Then all microtasks drain in the order they were queued: 3, 6, 7
// Then the macrotask runs: 2
// FINAL OUTPUT: 1, 4, 5, 8, 3, 6, 7, 2
// General strategy for these interview questions:
// 1. Read top to bottom, noting each line's category (sync/micro/macro)
// 2. List sync lines in original order first
// 3. List microtask lines in the order they were SCHEDULED (not written)
// 4. List macrotask lines last, in the order their timers were scheduled
// (shorter delays generally run before longer ones)
Event Loop and Asynchronous JavaScript
Understand how JavaScript achieves concurrency. Study call stack, callback queue, microtask queue, Web APIs, and the event loop cycle.
What is the call stack in JavaScript and how does it relate to being single-threaded?
The call stack is a data structure that tracks function calls - each time a function is invoked, a frame is pu...
What are Web APIs and how do they enable asynchronous behavior in JavaScript?
Web APIs (like setTimeout, fetch, DOM events) are provided by the browser (or Node.js runtime), not the JavaSc...
What is the difference between macrotasks and microtasks in the event loop?
Queue typeExamplesPriorityMicrotask queuePromise .then/.catch/.finally, queueMicrotask, MutationObserverHigher...
How does the event loop actually work step by step?
The event loop is the mechanism that continuously checks whether the call stack is empty, and if so, moves que...
Why does setTimeout(fn, 0) not run immediately?
Even with a 0ms delay, setTimeout's callback is placed in the macrotask queue and must wait for the current sy...
How does async/await interact with the event loop compared to raw Promises?
async/await doesn't change how the event loop fundamentally works - it's still built on Promises and microtask...
What is the difference between setTimeout and setInterval, and how do you clear them?
FunctionBehaviorClear withsetTimeoutRuns the callback ONCE after the delayclearTimeout(id)setIntervalRuns the...
How does JavaScript handle concurrency without multiple threads?
JavaScript achieves concurrency through non-blocking I/O and the event loop, rather than through true parallel...
What is 'starvation' in the event loop and how can microtasks cause it?
Since the microtask queue must be FULLY drained before the event loop moves to the next macrotask, a microtask...
How would you predict the output order of a code snippet mixing sync code, setTimeout, and Promises?
Predicting execution order requires classifying every line into one of three buckets: synchronous, microtask,...