Event loop
What the event loop is
JavaScript runs your code on a single thread: one thing at a time, on the call stack. The browser around it is not so limited. Timers keep ticking and network requests complete while your function runs, and the user keeps clicking. The event loop connects the two worlds: it decides when the callbacks from those outside events get their turn on the single thread.
The starting mental model:
- Synchronous code runs first and to completion. Nothing can interrupt a running function.
setTimeoutcallbacks, click handlers, and network callbacks go into a queue.- When the call stack is empty, the event loop takes the next callback from the queue and runs it.
Why setTimeout(fn, 0) doesn't run immediately
console.log("a");
setTimeout(() => console.log("b"), 0);
console.log("c");
// a, c, b
setTimeout(fn, 0) doesn't mean "run now". It means "put fn in the queue as soon as possible". The queued callback still waits for all current synchronous code to finish, which is why c prints before b.
Why promises run before setTimeout
Callbacks from promises (.then, and code after await) go into a separate queue with higher priority. When the sync code ends, promise callbacks run before any setTimeout callback:
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
// promise, timeout
At this level the rule of thumb is enough: sync code, then promise callbacks, then timers and events. Why the queues are split, and what each queue is called, is the middle-level story.
Microtasks vs macrotasks
The two queues from the junior level have names:
- Macrotasks (a.k.a. tasks):
setTimeout/setIntervalcallbacks, DOM events, message events. One macrotask is processed per loop iteration. - Microtasks: promise reactions (
.then/catch/finally),awaitcontinuations,queueMicrotask,MutationObserver. The microtask queue is drained completely after the current task, before the next macrotask.
"Drained completely" is the load-bearing rule. After every macrotask (and after the initial script), the engine runs microtasks until the queue is empty, including microtasks queued by other microtasks.
async/await desugars to promises
An async function runs synchronously until the first await. The await wraps its operand in a resolved promise and suspends the function; the rest of the function body becomes a microtask continuation.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
(async () => {
console.log("4"); // sync — runs immediately
await null; // suspend; continuation → microtask queue
console.log("5");
})();
console.log("6");
// 1, 4, 6, 3, 5, 2
Walk it: the sync pass logs 1, 4, 6 (the async body runs sync until await). The stack empties. Microtasks drain in enqueue order: 3 was queued before the await continuation, so it prints before 5. Only then the macrotask: 2.
Two misconceptions interviewers screen for:
- "
awaitblocks the thread." It suspends the function; the thread keeps going, which is why6prints before5. - "async functions start later." The body starts synchronously; only the post-
awaitpart is deferred.
When the browser repaints
The browser can repaint between macrotasks, never between microtasks. One iteration runs roughly: a task, then a full microtask drain, then (maybe) style/layout/paint. This is why a long chain of promise callbacks freezes the page just as badly as a long for loop: the paint never gets a slot.
Microtask starvation
Because the microtask queue drains fully before anything else proceeds, a microtask that queues another microtask can starve the loop:
function loop() { Promise.resolve().then(loop); }
loop(); // no timer ever fires again, no paint ever happens
The same code with setTimeout(loop, 0) is harmless: macrotasks yield to rendering and other tasks between iterations. This asymmetry is the practical reason the split exists. Microtasks guarantee "run before anything else observable happens" semantics (state consistency); macrotasks guarantee fairness.
Which scheduling API to use
queueMicrotask(fn): explicit microtask; same queue as promise reactions, FIFO with them.requestAnimationFrame(fn): runs before the next paint, not on the task queues; the right place for visual updates (guaranteed at most once per frame).setTimeout(fn, 0): next task, after possible rendering; also subject to clamping (nested timers clamp to ~4ms; background tabs throttle far more aggressively).scheduler.postTask()/requestIdleCallback: priority-aware and idle scheduling for cooperative chunking of long work.
The decision rule: when state must be consistent before anything else runs, use a microtask; for a visual update, rAF; to yield to the browser and then continue, a macrotask or scheduler.yield().
Node.js differences
Same language rules, different loop implementation (libuv) with phases: timers → pending callbacks → poll → check (setImmediate) → close callbacks. Between every phase transition, microtasks drain, and process.nextTick has its own queue that runs before promise microtasks. Hence the classic: setTimeout(fn, 0) vs setImmediate(fn) ordering is non-deterministic in the main script but deterministic inside an I/O callback, where setImmediate wins because the check phase follows poll.
One event loop iteration, step by step
Take one task, run it to completion, drain the microtask queue entirely (including newly queued ones), then a rendering opportunity (rAF, style, layout, paint), then the next task. Every "predict the output" puzzle and every UI-freeze bug reduces to this sequence plus two facts: async bodies run sync until await, and microtasks starve everything downstream.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.