← Bank
Conceptual

JavaScript Event Loop

ConceptualSenior~6m
javascriptevent-loopconcurrency

Prompt

Given a chunk of JS that mixes a setTimeout, a resolved promise's .then, and some synchronous code, walk me through the exact order things run — and explain the rule that decides it.

How this round runs

This is a conversation that escalates — I'll keep asking "and what happens when…" until I find the edge of what you know. I want the micro-task vs. macro-task ordering rule stated precisely, and I'll push into starving the loop and the subtle behavior of await inside a loop — reason it out rather than guessing.

Model answer

I'll state the ordering rule, then run an example against it. The engine runs synchronous code on the call stack to completion first. Async results arrive as queued jobs in two tiers: the micro-task queue (promise reactions — .then, the continuation after an await, queueMicrotask) and the macro-task queue (setTimeout, setInterval, I/O callbacks). The rule that decides order: after the current task finishes, the loop drains the entire micro-task queue — including micro-tasks scheduled by other micro-tasks — before it ever picks up the next macro-task. So micro-tasks always jump ahead of the next timer.

Run it: synchronous code runs first; a setTimeout(fn, 0) registers fn as a macro-task; a Promise.resolve().then(g) registers g as a micro-task. When the stack clears, the loop drains micro-tasks, so g runs before fn, even with a 0 ms timeout — because the timeout only governs eligibility, and the macro-task isn't considered until the micro-task queue is empty. That ordering is the thing people get wrong, and it's deterministic, not a race.

The edges. Starving the loop: because the loop drains micro-tasks fully before the next macro-task, a micro-task that keeps scheduling more micro-tasks (or a long synchronous block) never yields — timers and I/O callbacks are starved and the UI freezes; that's why heavy CPU work belongs on a worker, not the main thread. await inside a loop is the other trap, and it's two distinct cases: for…of with an await serializes — each iteration suspends and resumes before the next starts, so N awaited calls run one after another; if they're independent you usually want to start them all and await Promise.all(...) to overlap the I/O. My honest boundary: the macro-task families (timers vs. I/O vs. setImmediate) have a defined phase order in Node that differs from the browser's model, and I'd check the runtime's phase diagram before asserting cross-task ordering beyond the micro/macro rule.

Signals — what a strong answer shows
  • Stated the rule precisely: drain the ENTIRE micro-task queue before the next macro-task
  • Ran a concrete example and explained why .then beats setTimeout(0) — eligibility vs. scheduling
  • Connected it to a real consequence: deterministic ordering, not a race
  • Diagnosed starvation correctly — full micro-task drain means a micro-task loop or sync block freezes everything
  • Got await-in-a-loop right: for…of serializes; Promise.all overlaps; honest that Node phase order differs from the browser
Follow-ups — where it goes next
  • And what happens when a micro-task keeps queuing more micro-tasks? → the loop never reaches the next macro-task; timers/I-O starve, UI freezes
  • You await inside a for…of over ten independent requests — fast or slow, and why? → serialized, ten round trips back-to-back; use Promise.all to overlap them
  • Promise.then vs setTimeout(0) — which runs first and what's the underlying reason? → the then; micro-tasks drain fully before any macro-task is considered