The Event Loop Interview Questions & Answers
33 questions Updated 2026-06-17
JavaScript event loop interview questions — the call stack, task and microtask queues, and how asynchronous code is scheduled.
Read the in-depth guideThe JavaScript Event Loop — How Asynchronous Code Really WorksJavaScript runs on a single thread — one call stack, one thing at a time. The event loop is the coordinator that lets that single thread appear concurrent: it repeatedly checks "is the call stack empty?" and, when it is, pulls the next queued callback and pushes it onto the stack to run.
The pieces involved:
- Call stack — the function currently executing.
- Web/Node APIs — timers, network, I/O that run outside the engine and hand a callback back when done.
- Task & microtask queues — where those finished callbacks wait.
while (true) {
runUntilStackEmpty() // execute current synchronous work
drainMicrotasks() // then ALL queued microtasks
if (oneMacrotask) run(oneMacrotask) // then exactly one macrotask, repeat
}
So async code never "interrupts" running code — it waits until the stack is clear, which is why the event loop is the heart of every "what's the output order?" interview question.
The call stack is a LIFO (last-in, first-out) structure that tracks where the program is in its execution. Calling a function pushes a frame (its local variables and return address); returning pops it. JavaScript has exactly one call stack, so it executes one frame at a time.
function a() { b() }
function b() { c() }
function c() { throw new Error('boom') }
a()
// Stack at the throw (top first): c -> b -> a -> (global)
// That ordering is literally what a stack trace prints.
Two consequences worth naming in an interview: deep/infinite recursion
overflows the stack (Maximum call stack size exceeded), and because there's
only one stack, any long synchronous function blocks everything else until
it returns.
There are two priority levels of queued work, and the event loop treats them very differently:
- Macrotasks (a.k.a. "tasks"):
setTimeout,setInterval,setImmediate, I/O callbacks, UI events. The loop runs one per iteration. - Microtasks: promise
.then/catch/finallycallbacks,queueMicrotask,awaitcontinuations,MutationObserver.
The critical rule: after each macrotask (and after the initial synchronous script), the engine drains the entire microtask queue — including any microtasks those microtasks schedule — before picking up the next macrotask or rendering.
setTimeout(() => console.log('macro'), 0)
Promise.resolve()
.then(() => console.log('micro 1'))
.then(() => console.log('micro 2'))
// micro 1, micro 2, macro
// Both microtasks finish before the timer, even with a 0ms delay.
That "microtasks always win" behavior is why a resolved promise consistently
beats a setTimeout(0).
console.log('A')
setTimeout(() => console.log('B'))
Promise.resolve().then(() => console.log('C'))
console.log('D')
Output: A D C B. Walk it through the way you would on a whiteboard:
console.log('A')— synchronous, runs now -> A.setTimeout(...)— hands the callback to the timer API; it'll be queued as a macrotask. Nothing prints yet.Promise.resolve().then(...)— schedules its callback as a microtask. Nothing prints yet.console.log('D')— synchronous -> D.- Script finishes, stack is empty -> drain microtasks -> C.
- Next macrotask runs -> B.
The takeaway: all synchronous code first, then every microtask, then the next macrotask.
It blocks the single thread. While the loop runs the call stack never empties, so the event loop can't pull anything from the queues: timers don't fire on time, promise callbacks wait, clicks/scroll feel frozen, and the page can't repaint (the dreaded "page unresponsive").
const end = Date.now() + 3000
while (Date.now() < end) {} // freezes the tab for 3 full seconds
console.log('finally free')
Fixes: break the work into chunks that yield to the loop (setTimeout,
requestIdleCallback), or move CPU-heavy work off the main thread into a Web
Worker (browser) / Worker thread (Node) so the UI stays responsive.
Yes. Because the engine drains the whole microtask queue before moving on, a microtask that keeps scheduling more microtasks creates a queue that never empties — so macrotasks (timers, I/O) and rendering are postponed indefinitely. The tab effectively hangs even though "async" code is running.
function loop() {
queueMicrotask(loop) // re-queues itself forever -> macrotasks never run
}
loop()
setTimeout(() => console.log('I will never print'), 0)
To avoid starvation, yield to the macrotask queue for repeated/long work
(setTimeout, MessageChannel, or await a real async boundary) so the loop
gets a chance to run timers and paint frames between chunks.
queueMicrotask(fn) schedules a callback to run as a microtask — after the
current synchronous code finishes but before the next macrotask. It's the direct
way to queue a microtask without abusing Promise.resolve().then().
console.log('sync')
queueMicrotask(() => console.log('microtask'))
console.log('sync 2')
// sync, sync 2, microtask
Use it to defer work to "just after this task" while still beating timers — e.g. batching DOM reads or ensuring a callback runs after the current call stack unwinds but ASAP.
requestAnimationFrame(cb) schedules cb to run right before the next
repaint, typically ~60 times per second. It's neither a macrotask nor a
microtask — the browser runs rAF callbacks in a dedicated step of the rendering
pipeline, just before layout/paint.
requestAnimationFrame(() => {
el.style.transform = `translateX(${x}px)` // applied in sync with the frame
})
Use it for animations and visual updates so they're synced to the display
refresh (no tearing, no wasted frames). Unlike setTimeout, it pauses in
background tabs.
No. setTimeout(fn, 0) means "run as soon as possible as a macrotask," not
instantly. The HTML spec also clamps nested timeouts to a minimum (~4ms after
5 levels of nesting), and the callback still waits for the stack to clear and all
microtasks to drain.
setTimeout(() => console.log('timer'), 0)
Promise.resolve().then(() => console.log('microtask'))
// microtask runs first — the "0ms" timer is still a macrotask
So setTimeout(0) is "defer to the next task," useful for yielding, but never a
precise or immediate timer.
The core idea (stack + queues + microtask draining) is the same, but the implementations differ:
- The browser loop is defined by the HTML spec and integrates with rendering (rAF, paint, layout).
- Node uses libuv with distinct phases (timers, pending callbacks,
poll, check, close) and adds
process.nextTickandsetImmediate, with no rendering step.
// Node-only ordering nuance:
setImmediate(() => console.log('immediate'))
setTimeout(() => console.log('timeout'), 0)
process.nextTick(() => console.log('nextTick'))
// nextTick runs before both; immediate vs timeout order can vary
Microtasks (promises) drain between phases/tasks in both environments.
process.nextTick(fn) queues fn to run after the current operation
completes, before the event loop continues — and crucially before the
Promise microtask queue. So Node effectively has two micro-queues, with
nextTick having higher priority.
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
// nextTick, promise
Because it preempts everything, recursively scheduling nextTick can starve
the loop entirely. Prefer queueMicrotask/promises unless you specifically need
its priority.
console.log('1')
async function f() {
console.log('2')
await null
console.log('3')
}
f()
console.log('4')
// Output: 1, 2, 4, 3
f() runs synchronously up to the await (logs 2). await suspends the
function and schedules the rest (console.log('3')) as a microtask, so
control returns to the caller and 4 logs. After the synchronous code, the
microtask runs -> 3. The body before the first await is synchronous; only
what follows is deferred.
The browser tries to render at most once per frame (~16.7ms at 60fps), and it does so after the current macrotask and its full microtask queue have completed — never in the middle of a task.
macrotask -> drain microtasks -> (rAF callbacks) -> style/layout/paint -> next macrotask
Consequence: synchronous DOM changes within one task aren't painted individually — only the final state shows. And a long task blocks rendering entirely, which is why long synchronous work freezes the page.
await x pauses the function and registers the remaining code as a
continuation (a .then callback on x), which runs as a microtask once
x settles. The async function is essentially rewritten into promise
continuations by the engine.
async function g() {
const a = await fetchA() // everything below becomes a microtask continuation
const b = await fetchB() // ...and so does this, after a resolves
return a + b
}
So each await introduces a microtask boundary. This is why code after await
always runs after the current synchronous task, never inline.
setInterval queues the callback every N ms regardless of how long it takes
— if the callback is slow, executions can pile up or overlap conceptually.
Recursive setTimeout schedules the next run only after the current one
finishes, guaranteeing a gap.
// guarantees ≥1s between completions
function poll() {
doWork()
setTimeout(poll, 1000)
}
setTimeout(poll, 1000)
Recursive setTimeout is preferred for variable-duration work and for adapting
the delay dynamically; setInterval is fine for short, fixed-cadence ticks.
Each .then schedules its callback as a separate microtask, queued only when
the previous link resolves. So two independent chains interleave one
microtask at a time.
Promise.resolve().then(() => console.log('A1')).then(() => console.log('A2'))
Promise.resolve().then(() => console.log('B1')).then(() => console.log('B2'))
// A1, B1, A2, B2
A1 and B1 are queued first; running A1 queues A2, running B1 queues
B2. The engine drains the queue in FIFO order, producing the interleaving.
Break long synchronous work into chunks and schedule the next chunk as a macrotask so the loop can run timers, handle input, and paint in between.
function processChunk(i) {
const end = Math.min(i + 1000, data.length)
for (; i < end; i++) handle(data[i])
if (i < data.length) setTimeout(() => processChunk(i), 0) // yield
}
processChunk(0)
Modern options include scheduler.yield(), MessageChannel, or
requestIdleCallback. For pure CPU work, a Web Worker keeps the main thread
free entirely.
libuv runs the loop through ordered phases, each with its own callback queue:
- timers —
setTimeout/setIntervalcallbacks. - pending callbacks — deferred I/O callbacks.
- poll — retrieve new I/O events; execute I/O callbacks.
- check —
setImmediatecallbacks. - close —
closeevent callbacks.
Between every phase, Node drains process.nextTick and the microtask
(promise) queues. This phased structure is why setImmediate (check) and
setTimeout(0) (timers) can fire in different orders depending on context.
setImmediate runs in the check phase, after the poll phase, while
setTimeout(0) runs in the timers phase at the start of the next loop. Inside
an I/O callback, setImmediate is deterministically first; at the top level
their order is non-deterministic.
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
// inside I/O: "immediate" always logs first
})
Rule: to run "after the current I/O, ASAP," use setImmediate.
Yes. The function you pass to new Promise((resolve, reject) => {...}) runs
synchronously, immediately, as part of constructing the promise. Only the
.then/.catch callbacks are asynchronous (microtasks).
console.log('start')
new Promise(resolve => {
console.log('executor') // runs now, synchronously
resolve()
}).then(() => console.log('then'))
console.log('end')
// start, executor, end, then
A common misconception is that everything about a promise is async — the executor body is not.
await wraps any non-promise in Promise.resolve(value), so the value comes back
as-is — but the function still suspends for one microtask tick. So even
await 5 defers the rest of the function.
async function f() {
console.log('before')
await 5 // not a promise, but still yields a microtask
console.log('after')
}
f()
console.log('sync')
// before, sync, after
So await always introduces an async boundary, even for plain values — handy to
know for ordering puzzles.
No. JavaScript runs on a single thread; async just means work is scheduled to run later rather than blocking. The waiting (timers, network, file I/O) happens outside the JS engine (in the browser/OS/libuv), but your callbacks all run one-at-a-time on the same thread.
setTimeout(() => console.log('later'), 1000) // the timer is handled elsewhere;
// the callback still runs on the single JS thread when the stack is free
True parallelism in JS requires Web Workers / worker threads, which run separate threads communicating via messages.
JavaScript was designed single-threaded to keep the DOM model simple — if multiple threads could mutate the DOM concurrently, you'd need locks everywhere and face race conditions on every UI update. A single thread plus the event loop gives concurrency (handling many things) without the complexity of parallelism.
// no data races: only one piece of JS touches `count` at a time
let count = 0
button.onclick = () => count++
The trade-off is that CPU-heavy work blocks everything — solved by offloading to Web Workers when needed.
The callback queue (a.k.a. task/macrotask queue) holds callbacks that are ready to run but waiting for the call stack to clear — completed timers, resolved I/O, dispatched events. The event loop moves one of them onto the stack each iteration.
button.addEventListener('click', handler) // handler is queued when clicked
setTimeout(cb, 100) // cb is queued ~100ms later
It's strictly FIFO per task source, and the loop only pulls from it when the stack is empty and microtasks are drained.
setTimeout(() => {
console.log('timeout 1')
Promise.resolve().then(() => console.log('promise in timeout'))
}, 0)
setTimeout(() => console.log('timeout 2'), 0)
// timeout 1, promise in timeout, timeout 2
Each macrotask is followed by a full microtask drain before the next macrotask. So after "timeout 1" runs, its queued promise microtask executes before "timeout 2". This "microtasks between every macrotask" rule is the key to these puzzles.
A microtask checkpoint is the point where the engine drains the entire microtask queue. It happens after the current macrotask completes (the stack empties) and also after certain operations like a callback returning. All pending microtasks — and any they schedule — run to completion before the next macrotask or render.
// everything queued via .then/queueMicrotask runs at the next checkpoint,
// before any setTimeout callback
Understanding checkpoints explains why microtasks always "win" and why they can starve the loop if they keep scheduling more.
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
;(async () => {
console.log('D')
await null
console.log('E')
})()
console.log('F')
// A, D, F, C, E, B
Sync first: A, then the async IIFE runs to its await -> D, then F.
Microtasks drain next: C (queued before the await continuation), then E.
Finally the macrotask: B. Order of microtasks follows the order they were
queued.
A "long task" (>50ms of synchronous work) monopolizes the single thread, so the event loop can't process the click/keypress callback or repaint — the user perceives lag. This directly worsens the INP (Interaction to Next Paint) metric.
button.onclick = () => {
heavyCompute() // blocks -> the visual response is delayed
}
Fixes: break work into chunks that yield, defer non-urgent work
(requestIdleCallback), debounce, or move computation to a Web Worker so input
handling and painting stay snappy.
requestAnimationFrame runs in sync with the display refresh (right before
paint) and pauses in background tabs, so animations are smooth and don't waste
CPU/battery. setTimeout fires on an arbitrary schedule unrelated to the frame,
causing dropped or doubled frames and jank.
function animate() {
move()
requestAnimationFrame(animate) // one update per frame, perfectly timed
}
requestAnimationFrame(animate)
rAF also self-throttles to the screen's refresh rate, whereas a fixed
setTimeout(16) drifts and can't adapt to 120Hz displays.
Running one macrotask then draining microtasks (and giving rendering a chance) keeps the system responsive and fair: it prevents a flood of queued tasks from blocking input handling and painting, and ensures microtask-based work (promises) completes promptly between tasks.
[1 macrotask] -> [all microtasks] -> [maybe render] -> [1 macrotask] -> ...
If the loop drained the whole macrotask queue at once, a burst of timers/events could lock out rendering and user input until they all finished.
Synchronous code runs top-to-bottom, each statement blocking until it finishes. Asynchronous code starts an operation and continues without waiting, handling the result later via a callback/promise — so the thread isn't blocked.
const data = readFileSync('f.txt') // sync: blocks here until done
readFile('f.txt', (e, data) => {}) // async: returns immediately, callback later
Async is essential in single-threaded JS so that slow I/O (network, disk) doesn't freeze the UI — the event loop schedules the continuation when the result is ready.
A Web Worker runs JavaScript on a separate thread with its own event loop, so
CPU-heavy work there doesn't block the main thread's loop (and thus the UI). They
communicate via postMessage, which queues a message-event (macrotask) on
the receiving side.
const worker = new Worker('heavy.js')
worker.postMessage(bigData)
worker.onmessage = e => render(e.data) // runs on main thread when free
Workers don't share memory by default (except SharedArrayBuffer), avoiding data
races. Use them to keep the main loop free for input and rendering.
To run something after the current synchronous code without blocking, schedule it on a queue. The choice of queue controls priority:
queueMicrotask(fn) // ASAP: before the next render/macrotask
Promise.resolve().then(fn) // also a microtask
setTimeout(fn, 0) // next macrotask (after microtasks + maybe a paint)
requestAnimationFrame(fn) // before the next paint
Pick a microtask when the deferral must beat rendering/timers, and a macrotask when you want to yield the thread (let input/paint happen) before continuing.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.