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 Works

JavaScript 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/finally callbacks, queueMicrotask, await continuations, 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:

  1. console.log('A') — synchronous, runs now -> A.
  2. setTimeout(...) — hands the callback to the timer API; it'll be queued as a macrotask. Nothing prints yet.
  3. Promise.resolve().then(...) — schedules its callback as a microtask. Nothing prints yet.
  4. console.log('D') — synchronous -> D.
  5. Script finishes, stack is empty -> drain microtasks -> C.
  6. 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.nextTick and setImmediate, 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:

  1. timerssetTimeout/setInterval callbacks.
  2. pending callbacks — deferred I/O callbacks.
  3. poll — retrieve new I/O events; execute I/O callbacks.
  4. checksetImmediate callbacks.
  5. closeclose event 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.