The Event Loop, Microtasks, Macrotasks and Output Prediction
The single most asked JavaScript question at Google, Meta, and every senior frontend loop.
Master the Call Stack, Microtask Queue, Macrotask Queue, the Rendering phase,
queueMicrotask, andMutationObserver, then you can predict the output of any snippet.
Table of Contents
- 1. The Mental Model
- 2. Call Stack
- 3. Macrotask Queue
- 3a. WebSocket and the Event Loop
- 4. Microtask Queue
- 5. The One Rule That Solves Every Question
- 6. Rendering Phase and requestAnimationFrame
- 7. queueMicrotask
- 8. MutationObserver
- 9. Worked Output Examples
- 10. async await Decoded
- 11. Cheat Sheet and Priority Table
- 12. Interview Questions and Answers
- 13. Node.js Event Loop Phases and Special Queues
- 14. Promise Combinator Timing
- 15. await Inside Loops
- 16. Blocking the Main Thread and Fixing It
- 17. Web Workers and Their Own Event Loop
1. The Mental Model
JavaScript is single-threaded: one call stack, one thing at a time. The event loop
is the coordinator that decides what runs next when the stack is empty.
flowchart TB
CS["Call Stack\n(runs synchronous code)"]
MIC["Microtask Queue\n(Promises, queueMicrotask, MutationObserver)"]
MAC["Macrotask Queue\n(setTimeout, setInterval, I/O, events)"]
RENDER["Render\n(style, layout, paint, rAF)"]
CS -->|stack empty| MIC
MIC -->|drain ALL| RENDER
RENDER -->|maybe| MAC
MAC -->|take ONE| CS
Before the loop makes sense, you need to know the two kinds of "jobs" it juggles.
Learn them in this order: macrotask → microtask → who wins → the loop.
Step 1: What is a Macrotask?
A macrotask is a big, standalone job scheduled by the browser. Think of it as
"one full run of a callback." The initial <script> is a macrotask, and so is each
timer/event callback.
setTimeout(() => console.log("I am a macrotask"), 0);
button.addEventListener("click", () => {}); // each click = one macrotask
Common macrotask sources: setTimeout, setInterval, DOM events (click, scroll),
network/I/O callbacks, MessageChannel.
Rule: the loop runs only ONE macrotask per turn.
Step 2: What is a Microtask?
A microtask is a small, urgent follow-up job, mostly things related to Promises.
It is meant to run as soon as the current code finishes, before the browser moves on
to anything else.
Promise.resolve().then(() => console.log("I am a microtask"));
queueMicrotask(() => console.log("also a microtask"));
Common microtask sources: Promise.then / catch / finally, await (the code after it),
queueMicrotask, MutationObserver.
Rule: the loop runs ALL microtasks per turn, and any microtask added while
draining also runs in the same turn.
Step 3: Who Wins? (Priority)
When the call stack empties, the loop always follows this priority:
1. Synchronous code (runs first, top to bottom)
2. ALL microtasks (drain the whole queue)
3. Render (maybe paint the screen)
4. ONE macrotask (then go back to step 2)
Microtasks always beat macrotasks. A
Promise.thenalways runs before a
setTimeout(…, 0), even though both were "scheduled for later."
Step 4: The Loop, With an Example
The loop repeats forever: run one macrotask → drain all microtasks → maybe render → repeat.
console.log("A"); // sync
setTimeout(() => console.log("B"), 0); // macrotask
Promise.resolve().then(() => console.log("C")); // microtask
console.log("D"); // sync
Walk it through with the priority list:
| Phase | What runs | Output so far |
|---|---|---|
| Sync code | console.log("A") |
A |
| Sync code | schedule B (macrotask), schedule C (microtask) |
A |
| Sync code | console.log("D") |
A D |
| Stack empty → drain microtasks | C |
A D C |
| Take one macrotask | B |
A D C B |
Output: A D C B
That is the entire skill: label each line as sync / micro / macro, then apply
"sync first, all micro next, one macro last." Everything below is just practice.
2. Call Stack
The call stack tracks the currently executing function. Synchronous code runs
top-to-bottom, frames pushed on call and popped on return.
function a() { b(); }
function b() { console.log("hello"); }
a();
push a() ─► push b() ─► console.log ─► pop b ─► pop a ─► stack empty
Nothing from any queue runs until the stack is completely empty. This is why a long
synchronous loop freezes the page, timers, promises, clicks, everything waits.
3. Macrotask Queue
A macrotask (a.k.a. "task") is a discrete unit of work scheduled by the host.
| Source | Notes |
|---|---|
setTimeout / setInterval
|
Even setTimeout(fn, 0) waits for the next tick + min clamp (~4ms nested) |
setImmediate |
Node.js only |
MessageChannel / postMessage
|
Fast macrotask, used by libraries |
| DOM events |
click, scroll, input dispatched from user actions |
| I/O, network callbacks | Host-driven |
requestIdleCallback |
Runs in idle periods |
Only one macrotask is processed per event-loop iteration. After it finishes, the
microtask queue is fully drained before the next macrotask.
3a. WebSocket and the Event Loop
WebSocket does not add a new queue. Its network traffic is handled off the main
thread by the browser's networking layer; when something happens (connection opens, a
message arrives, it closes, or errors), the browser queues a macrotask that dispatches
the corresponding event. Your handler runs like any other macrotask, only once the call
stack is empty and it is that task's turn.
const ws = new WebSocket("wss://example.com");
ws.onopen = () => console.log("open"); // macrotask
ws.onmessage = (e) => console.log(e.data); // macrotask (one PER message)
ws.onerror = () => console.log("error"); // macrotask
ws.onclose = () => console.log("close"); // macrotask
flowchart LR
NET["Network layer\n(off main thread)"] -->|frame received| MAC["Macrotask queue\n(message event)"]
MAC -->|stack empty + its turn| CS["onmessage handler\nruns on call stack"]
CS -->|handler done| MICRO["drain microtasks"]
MICRO --> NEXT["next macrotask\n(next message)"]
Key facts for output prediction
| Fact | Consequence |
|---|---|
Each open/message/close/error = one macrotask
|
Behaves like a setTimeout callback: runs after sync code + full microtask drain |
| Microtasks still win | A Promise.then from sync code runs before a pending onmessage
|
| One message handled per tick | 100 messages → 100 macrotasks, each with a microtask drain + possible paint between them |
| Handler blocks the loop | A slow onmessage freezes the UI and delays later messages; offload heavy parsing to a Web Worker |
| Fast producer, slow consumer | Messages back up in the task queue if handlers can't keep pace, batch or process in a worker |
Mental model: a WebSocket is just a stream of macrotasks. Everything you know about
setTimeoutordering applies directly toonmessage.
4. Microtask Queue
A microtask is a short job that must run before the browser does anything else
(before rendering, before the next macrotask).
| Source | Notes |
|---|---|
Promise.then / catch / finally |
Callback queued when the promise settles |
await continuation |
Everything after await is a microtask |
queueMicrotask(fn) |
Direct API to enqueue a microtask |
MutationObserver callback |
Fires as a microtask after DOM mutations |
Key property: after each macrotask, the loop drains the entire microtask queue, and if
a microtask schedules another microtask, that one runs in the same drain cycle.
A microtask that keeps queueing microtasks can starve rendering and macrotasks forever.
5. The One Rule That Solves Every Question
1. Run all synchronous code (the current macrotask).
2. Empty the ENTIRE microtask queue.
3. Render if needed.
4. Take ONE macrotask. Go to 2.
Apply it mechanically:
console.log("1"); // sync
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4"); // sync
Trace:
- Sync run:
1, schedule timeout, schedule promise,4. - Stack empty → drain microtasks:
3. - Take one macrotask:
2.
Output: 1 4 3 2
6. Rendering Phase and requestAnimationFrame
The browser tries to paint at ~60fps (every ~16.6ms). Rendering happens between
macrotasks, but only after the microtask queue is empty.
flowchart LR
MAC["1 macrotask"] --> MIC["drain ALL microtasks"]
MIC --> RAF["requestAnimationFrame callbacks"]
RAF --> STYLE["style + layout + paint"]
STYLE --> NEXT["next macrotask"]
Ordering facts to memorize:
-
requestAnimationFrameruns just before paint, after microtasks, before layout/paint. - It is not a macrotask and not a microtask, it belongs to the render steps.
- A microtask scheduled inside a
rAFcallback runs before the paint that follows it. - The browser may skip rendering entirely if nothing is dirty or the tab is hidden.
console.log("script start");
requestAnimationFrame(() => console.log("rAF"));
Promise.resolve().then(() => console.log("microtask"));
setTimeout(() => console.log("timeout"), 0);
console.log("script end");
Typical output: script start → script end → microtask → rAF → timeout
(rAF before timeout because the render step precedes the next macrotask; exact rAF
timing depends on when a frame is due).
Deep Dive: How the Browser Scheduler Decides When to Render
The event loop does not paint after every macrotask. Painting is expensive, so the
browser coalesces work and repaints on a display-synchronized cadence (VSync),
typically every ~16.67ms on a 60Hz screen (or ~6.94ms on 144Hz). This target interval
is the frame budget: everything, your JS, style, layout, paint, must fit inside it or
the frame is dropped (visible as jank/stutter).
Frame budget on a 60Hz display ≈ 16.67ms
|◄──────────────── one frame (16.67ms) ────────────────►|
| JS (tasks + microtasks) | rAF | style | layout | paint |
you control this ▲ browser owns this ▲
The full "frame" sequence the browser runs when it decides a frame is due:
flowchart TB
A["Run a macrotask\n(e.g. input, timer)"] --> B["Drain ALL microtasks"]
B --> C{"Is a frame due?\n(VSync tick reached)"}
C -->|no| A
C -->|yes| D["requestAnimationFrame callbacks"]
D --> E["Run ResizeObserver / IntersectionObserver"]
E --> F["Recalculate style"]
F --> G["Layout (reflow)"]
G --> H["Paint + Composite"]
H --> A
Key scheduler facts (senior-interview level):
| Concept | What it means |
|---|---|
| VSync alignment | Repaints are aligned to the monitor's refresh signal, not to your JS. rAF fires right before this render step. |
| Frame budget (~16.67ms) | If JS + style + layout + paint exceed the budget, the frame is missed → FPS drops below 60. |
| rAF is render-phase, not a queue | It is not a macrotask or microtask. Its callbacks run once per frame, immediately before style/layout/paint. |
| Coalescing | Multiple setTimeouts or events between frames do not each trigger a paint; the browser paints at most once per VSync. |
| Throttling when hidden | In a background tab, rAF stops firing and timers are clamped (≥1s), the browser skips rendering entirely to save power. |
| Off-main-thread compositing | Transforms/opacity animations can be composited on the GPU thread, avoiding main-thread layout/paint, this is why transform animates smoother than top/left. |
Why requestAnimationFrame Is Different From Both Queues
requestAnimationFrame((timestamp) => {
// timestamp = high-res time of THIS frame's render start (shared by all rAF cbs)
});
- Timing: runs after microtasks are drained, once per frame, synchronized to the display, right before layout/paint. A microtask runs many times per frame; a macrotask is unrelated to frames.
- Purpose: it is the only correct place to make visual changes, because your DOM writes land just before the browser lays out and paints, so they show up in the very next frame with no wasted intermediate paint.
-
Batching: all
rAFcallbacks scheduled for a frame receive the sametimestamp, and run back-to-back before a single paint, ideal for animation loops.
function animate(now) {
moveBox(now); // mutate DOM here (write phase)
requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);
How Browsers Avoid Jank (and How You Help)
Jank = a frame that misses its budget, so the screen doesn't update on time.
- Keep main-thread work per frame under ~5–10ms to leave room for the browser's own style/layout/paint within the ~16.67ms budget.
-
Avoid layout thrashing: batch DOM reads then writes. Interleaving
read → write → readforces synchronous reflows (forced layout / "layout thrashing").
// BAD: read, write, read, write → multiple forced reflows
el.style.width = el.offsetWidth + 10 + "px";
// GOOD: read all, then write all
const w = el.offsetWidth; // read
requestAnimationFrame(() => { // write in the frame's render step
el.style.width = w + 10 + "px";
});
-
Prefer
transform/opacityfor animation, they can skip layout & paint and run on the compositor thread. -
Chunk long tasks so a single macrotask never blocks a frame. Yield with
setTimeout,MessageChannel, or the modernscheduler.postTask()/scheduler.yield()APIs; useisInputPending()to yield when the user is interacting. - Do heavy work off-thread in a Web Worker so the main thread stays free to render.
Mental model for interviews: microtasks and macrotasks decide what runs, but the
display's VSync decides when the browser paints.requestAnimationFrameis your hook
into that render cadence, run once per frame, right before layout and paint, so your
visual updates land in sync with the screen instead of causing extra, wasted repaints.
7. queueMicrotask
queueMicrotask(fn) is the explicit, purpose-built way to schedule a microtask,
without the overhead or semantics of creating a promise.
queueMicrotask(() => console.log("runs as a microtask"));
Why prefer it over Promise.resolve().then(...):
| Aspect | queueMicrotask |
Promise.resolve().then |
|---|---|---|
| Intent | Explicit "schedule a microtask" | Side effect of promise chaining |
| Allocation | No promise object | Allocates a promise |
| Error handling | Throws become uncaught (reported to global) | Swallowed into rejected promise |
| Ordering | Same microtask queue, FIFO | Same microtask queue, FIFO |
Use it to defer work until after the current synchronous block but before rendering
or timers, e.g. batching state updates while preserving synchronous-looking order.
console.log("A");
queueMicrotask(() => console.log("B"));
console.log("C");
// Output: A C B
8. MutationObserver
What Is It?
MutationObserver is a built-in browser API that watches an element for DOM changes
and notifies you asynchronously when they happen. Instead of you repeatedly polling the
DOM ("did anything change yet?"), you register a callback once and the browser calls it
whenever the parts of the DOM you asked about are modified.
const observer = new MutationObserver((mutations) => {
// called after the DOM changes you subscribed to
});
observer.observe(targetNode, {
childList: true, // nodes added/removed
attributes: true, // attribute changes (class, style, data-*, ...)
characterData: true,// text content changes
subtree: true, // also watch all descendants
});
observer.disconnect(); // stop watching when you're done (avoid leaks)
What Is It Used For?
| Use case | Why MutationObserver
|
|---|---|
| Reacting to DOM you don't control | Third-party widgets, ads, or CMS content inject nodes, observe and adapt |
| Detecting when an element appears | Wait for a lazily-rendered node before running code on it |
| Auto-resize / re-layout | Recompute sizes when children are added/removed |
| Syncing state to DOM edits | e.g. a rich-text editor reacting to contenteditable changes |
| Framework internals & testing | Detect when components mount/unmount; test utilities wait for DOM to settle |
| Enforcing invariants | Re-apply an attribute/class if something external strips it |
Why It Belongs in an Event-Loop Discussion
MutationObserver watches DOM changes (child list, attributes, text) and invokes its
callback as a microtask, batching all mutations since the last delivery.
const target = document.getElementById("box");
const observer = new MutationObserver((mutations) => {
console.log("mutations:", mutations.length); // microtask
});
observer.observe(target, { childList: true, attributes: true, subtree: true });
target.setAttribute("data-x", "1");
target.appendChild(document.createElement("span"));
console.log("sync done");
// Output:
// sync done
// mutations: 2 ← batched, delivered as ONE microtask
Why it matters for output prediction:
- The callback is a microtask, so it runs in the same drain cycle as promises.
- Multiple synchronous mutations are coalesced into a single callback invocation.
- It runs before
setTimeoutand before the next paint.
Historically (pre-queueMicrotask), libraries abused MutationObserver on a dummy text
node just to get a fast microtask, that trick is now obsolete, use queueMicrotask.
9. Worked Output Examples
Example A, microtasks jump the timer queue
console.log("start");
setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
setTimeout(() => console.log("timeout 2"), 0);
console.log("end");
Trace:
- Sync:
start,end(both timeouts + promise chain scheduled). - Drain microtasks:
promise 1, then its.thenqueues →promise 2. - Macrotask 1:
timeout 1. - Macrotask 2:
timeout 2.
Output:
start
end
promise 1
promise 2
timeout 1
timeout 2
Example B, nested microtask inside a macrotask
setTimeout(() => {
console.log("timeout");
Promise.resolve().then(() => console.log("inner promise"));
}, 0);
Promise.resolve().then(() => console.log("outer promise"));
console.log("sync");
Output:
sync
outer promise
timeout
inner promise
The inner promise is queued inside the timeout macrotask, so its microtask drains
immediately after that macrotask, before any later macrotask.
Example C, queueMicrotask vs Promise ordering (FIFO)
queueMicrotask(() => console.log("mt 1"));
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("mt 2"));
Output: mt 1 → promise → mt 2 (single queue, strict FIFO by scheduling order).
Example D, microtask starvation vs macrotask
setTimeout(() => console.log("macrotask"), 0);
function loopMicro(n) {
if (n === 0) return;
queueMicrotask(() => {
console.log("micro", n);
loopMicro(n - 1);
});
}
loopMicro(3);
Output: micro 3 → micro 2 → micro 1 → macrotask
(all microtasks drain, recursively, before the single macrotask).
Example E, the classic async/await + timer mix
console.log("1");
setTimeout(() => console.log("2"), 0);
async function run() {
console.log("3");
await Promise.resolve();
console.log("4");
}
run();
Promise.resolve().then(() => console.log("5"));
console.log("6");
Trace:
- Sync:
1, schedule timeout, callrun()→3(up toawait), schedule promise5,6. - The code after
await(4) is queued as a microtask first, then5. - Drain microtasks in FIFO order:
4, then5. - Take one macrotask:
2.
Output:
1
3
6
4
5
2
Example F, Promise.resolve in a value vs a .then
console.log("start");
Promise.resolve().then(() => console.log("A")).then(() => console.log("B"));
Promise.resolve().then(() => console.log("C")).then(() => console.log("D"));
console.log("end");
Two chains are drained in lockstep: each .then re-queues onto the back of the
microtask queue, so the ticks interleave rather than one chain finishing first.
Trace:
- Sync:
start,end. Queue hasA,C. - Tick:
Aruns → queuesB.Cruns → queuesD. Queue nowB,D. - Tick:
B, thenD.
Output:
start
end
A
C
B
D
WebSocket examples
These assume the socket is already open and messages/handlers are ready to fire. Remember:
onmessage is a macrotask, so microtasks always jump ahead of it.
Example G, microtask beats an incoming message
console.log("sync start");
ws.onmessage = () => console.log("ws message");
Promise.resolve().then(() => console.log("promise"));
console.log("sync end");
Assuming a message is already queued when this script runs:
Trace:
- Sync:
sync start, register handler, schedule promise,sync end. - Stack empty → drain microtasks:
promise. - Take one macrotask (the message):
ws message.
Output:
sync start
sync end
promise
ws message
Example H, each message is a separate macrotask
let count = 0;
ws.onmessage = () => {
count++;
console.log("message", count);
Promise.resolve().then(() => console.log(" after", count));
};
// Three frames arrive back-to-back.
Each message runs as its own macrotask, and its microtask drains before the next
message is processed:
Output:
message 1
after 1
message 2
after 2
message 3
after 3
Not message 1 message 2 message 3 then all the afters, because the microtask queue is
emptied after each macrotask.
Example I, message vs setTimeout ordering
setTimeout(() => console.log("timeout"), 0);
ws.onmessage = () => console.log("ws message");
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
First, the guaranteed part:
-
syncprints (synchronous code). -
promiseprints next (microtasks drain before any macrotask). -
ws messageandtimeoutare both macrotasks, so they come last.
Now the part people get wrong, why ws message before timeout? It is not a
priority rule; both are equal-priority macrotasks. It comes down to which task landed in
the queue first:
-
setTimeout(fn, 0)is not actually 0ms. The browser clamps it to a minimum (~4ms, and more if nested/throttled). The timer callback is only enqueued after that delay elapses. - A WebSocket
messageis enqueued the moment the frame arrives from the network.
So if the socket already has a message waiting (or one arrives within those few
milliseconds), that macrotask is sitting in the queue before the timer's delay is up,
and the loop takes it first.
Output (message already queued before the ~4ms timer elapses):
sync
promise
ws message
timeout
Flip it: if no message has arrived yet and the 4ms passes first, you'd get
timeout
thenws message. The order of two macrotasks is decided purely by enqueue time,
not by type. Only the sync-then-microtask part is guaranteed.
Example J, a slow handler blocks everything
ws.onmessage = () => {
const end = Date.now() + 200;
while (Date.now() < end) {} // 200ms of blocking work
console.log("processed");
};
While this handler runs, nothing else happens: no other message is processed, no
promise resolves, no frame paints, the tab appears frozen for 200ms. Fix by chunking the
work or moving parsing into a Worker:
const worker = new Worker("parse.js");
ws.onmessage = (e) => worker.postMessage(e.data); // hand off, stay responsive
worker.onmessage = (e) => render(e.data); // still a macrotask, but cheap
Example K, queueMicrotask that spawns a timer and a microtask
console.log(1);
setTimeout(() => console.log(2));
queueMicrotask(() => {
console.log(3);
setTimeout(() => console.log(4));
Promise.resolve().then(() => {
console.log(5);
});
});
Promise.resolve().then(() => {
console.log(6);
});
console.log(7);
Trace:
-
Sync:
1, schedule timer2(macrotask), queue microtask M1 (the block), queue microtask M2 (6),7. → prints1,7. -
Drain microtasks (FIFO: M1, then M2):
-
M1:
3; schedules timer4(macrotask, goes after timer2); queues microtask M3 (5) onto the back. → prints3. Queue now:[M2, M3]. -
M2:
6. -
M3:
5.
-
M1:
-
Macrotasks (in enqueue order): timer
2, then timer4. → prints2,4.
Output:
1
7
3
6
5
2
4
Key takeaways:
- The microtask spawned inside M1 (
5) still runs in the same drain, but after the already-queued6, because it joins the back of the queue. - The timer scheduled inside a microtask (
4) is a macrotask, so it waits until after every microtask and after the earlier timer (2).
10. async await Decoded
await X is syntactic sugar: everything after the await becomes a microtask
continuation that runs once X settles.
async function foo() {
console.log("foo start");
await null; // suspends here → rest is a microtask
console.log("foo end");
}
console.log("script start");
foo();
Promise.resolve().then(() => console.log("promise"));
console.log("script end");
Trace:
script start-
foo start(runs synchronously up to theawait) script end- Microtasks in order queued:
foo end, thenpromise
Output:
script start
foo start
script end
foo end
promise
Rule of thumb: split every
asyncfunction at eachawait. Code before the first
awaitis synchronous, each subsequent chunk is a separate microtask.
11. Cheat Sheet and Priority Table
Execution priority when the stack empties (highest first):
| Priority | Category | Examples |
|---|---|---|
| 1 | Synchronous | Current call stack code |
| 2 | Microtasks (all) |
Promise.then, await, queueMicrotask, MutationObserver
|
| 3 | Render steps |
requestAnimationFrame, style, layout, paint |
| 4 | Macrotask (one) |
setTimeout, setInterval, DOM events, I/O, MessageChannel
|
Golden rules:
- Stack must be empty before any queue runs.
- All microtasks drain per tick; only one macrotask runs per tick.
- Microtasks scheduled during a drain run in the same drain cycle.
-
requestAnimationFrameruns after microtasks, before paint, and before the next macrotask. -
setTimeout(fn, 0)is not immediate, minimum ~4ms clamp when nested. - Infinite microtask chains starve rendering and timers.
12. Interview Questions and Answers
Q1. Why does a promise callback run before setTimeout(fn, 0)?
Because promise callbacks are microtasks, and the entire microtask queue drains after the
current task and before the next macrotask. setTimeout schedules a macrotask, which is
lower priority.
Q2. Difference between queueMicrotask and Promise.resolve().then?
Both enqueue onto the same microtask queue with FIFO ordering. queueMicrotask is explicit,
allocates no promise, and surfaces thrown errors to the global handler instead of turning
them into a rejected promise.
Q3. Is requestAnimationFrame a macrotask or microtask?
Neither. It is part of the browser's render steps, running after microtasks are drained
and just before layout/paint, at most once per frame.
Q4. When does a MutationObserver callback fire?
As a microtask, after the current synchronous code, batching all DOM mutations since the
last delivery into a single invocation, before rendering and before macrotasks.
Q5. Can microtasks block rendering?
Yes. Because all microtasks (including ones scheduled during the drain) must finish before
rendering, an ever-growing microtask chain starves the render step and freezes the UI.
Q6. Predict the output:
console.log(1);
setTimeout(() => console.log(2));
queueMicrotask(() => console.log(3));
Promise.resolve().then(() => console.log(4));
console.log(5);
Answer: 1 5 3 4 2 — sync (1,5), microtasks FIFO (3,4), then the macrotask (2).
13. Node.js Event Loop Phases and Special Queues
Everything above describes the browser event loop. Node.js uses libuv and has a
different, phase-based loop. Knowing this split is a big interview differentiator.
The Six Phases
Each full turn of the Node loop walks through these phases in order; each phase has its
own callback queue that it drains before moving on.
flowchart TB
T["1. timers\nexpired setTimeout / setInterval"] --> P["2. pending callbacks\nsome deferred system/IO callbacks"]
P --> I["3. idle / prepare\n(internal use only)"]
I --> POLL["4. poll\nretrieve new I/O events; run I/O callbacks"]
POLL --> CH["5. check\nsetImmediate callbacks"]
CH --> CL["6. close callbacks\ne.g. socket.on('close')"]
CL --> T
| Phase | What runs here |
|---|---|
| timers | Callbacks whose setTimeout / setInterval delay has elapsed |
| pending callbacks | A few system operations' callbacks deferred from the previous loop |
| idle / prepare | Internal only |
| poll | The heart: waits for and processes I/O (file, network) callbacks |
| check |
setImmediate callbacks, run right after poll |
| close |
close events (e.g. socket.on('close', ...)) |
The Two Special Queues: process.nextTick and Promises
Node has two microtask-like queues that run between every phase (and after each
individual callback), not tied to any single phase:
-
process.nextTickqueue — highest priority, drained first. -
Promise microtask queue — (
.then/await/queueMicrotask), drained after thenextTickqueue.
After EACH callback (and between phases), Node drains, in this order:
1. the ENTIRE process.nextTick queue
2. the ENTIRE Promise microtask queue
then it continues to the next callback / phase.
process.nextTickbeats Promises. This is the Node-specific twist:nextTick
callbacks run before any promise callback, and both run before the loop advances to the
next phase. Recursively queuingnextTickcan starve the I/O loop entirely.
Priority Order (Node)
1. Synchronous code
2. process.nextTick queue (drained fully)
3. Promise microtask queue (drained fully)
4. Current phase's macrotask callbacks (timers / poll / check / ...)
→ then repeat 2–3 before the next callback/phase
Worked Example: nextTick vs Promise vs timers vs setImmediate
console.log("1: sync start");
setTimeout(() => console.log("2: setTimeout"), 0);
setImmediate(() => console.log("3: setImmediate"));
Promise.resolve().then(() => console.log("4: promise"));
process.nextTick(() => console.log("5: nextTick"));
console.log("6: sync end");
Trace:
-
Sync:
1, schedule timer, schedule immediate, queue a promise, queue a nextTick,6. - Stack empty → drain nextTick first:
5. - Drain Promise microtasks:
4. - Enter phases: timers phase runs the elapsed timer:
2. -
check phase runs
setImmediate:3.
Output:
1: sync start
6: sync end
5: nextTick
4: promise
2: setTimeout
3: setImmediate
setTimeout(fn, 0) vs setImmediate — the classic "it depends"
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
At the top level, the order is not guaranteed — it depends on how fast the process
starts relative to the ~1ms timer threshold, so you may see either order.
But inside an I/O callback (the poll phase), setImmediate always wins, because the
check phase comes immediately after poll, whereas the next timers phase is a
full loop away:
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// Always: immediate → timeout
Node vs Browser, Key Differences
| Browser | Node.js | |
|---|---|---|
| Model | One task queue + one microtask queue + render steps | Phase-based libuv loop (timers/poll/check/…) |
| Highest priority | Microtasks (Promises) |
process.nextTick, then Promises |
| "Run after I/O, this loop" | — |
setImmediate (check phase) |
| Rendering |
requestAnimationFrame + paint |
No rendering (server) |
| Microtask drain | After each macrotask | After each callback and between phases |
Interview soundbite: In Node,
process.nextTick> Promise microtasks > the current
phase's callbacks.setImmediate(check phase) beatssetTimeout(0)(timers phase) when
scheduled from within an I/O callback.
14. Promise Combinator Timing
The combinators (Promise.all, race, allSettled, any) don't settle synchronously.
Each one attaches its own .then internally to every input promise, so resolving the
combined promise costs extra microtask ticks. This is a classic hard output question.
The core rule
A combinator's
.thenfires after the.thens of its already-resolved inputs,
because the combinator itself must first observe each input settle (one microtask hop),
then settle its own promise (another hop).
Example: Promise.all vs a direct .then
const a = Promise.resolve("a");
const b = Promise.resolve("b");
Promise.all([a, b]).then(() => console.log("all done"));
a.then(() => console.log("a then"));
b.then(() => console.log("b then"));
console.log("sync");
Trace (by microtask tick):
-
Sync:
sync.Promise.allinternally attaches.thentoaandb. -
Tick 1:
a then,b thenrun (direct handlers). Internally,allalso records thataandbare now fulfilled, on the second of these it schedules its own resolution. -
Tick 2:
Promise.all's combined promise resolves → its.thenruns:all done.
Output:
sync
a then
b then
all done
all done lands after the individual .thens, even though every input was already
resolved, because the combinator needs an extra tick to settle.
Promise.race — settles on the first input, still one hop
const fast = Promise.resolve("fast");
const slow = new Promise((r) => setTimeout(r, 100, "slow"));
Promise.race([fast, slow]).then((v) => console.log("race:", v));
fast.then((v) => console.log("fast:", v));
console.log("sync");
Output:
sync
fast: fast
race: fast
race mirrors the first settled input (fast), but its .then still runs one tick after
fast's own .then. The slow timer never affects the result.
allSettled — never rejects, waits for all to settle
const ok = Promise.resolve(1);
const bad = Promise.reject("boom");
Promise.allSettled([ok, bad]).then((results) => console.log(results));
console.log("sync");
Output:
sync
[
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: "boom" }
]
Unlike all (which rejects the moment any input rejects), allSettled waits for every
input to settle and resolves with a status array, so no .catch is needed.
Combinator cheat sheet
| Combinator | Settles when | On rejection | Extra ticks |
|---|---|---|---|
Promise.all |
all fulfill | rejects on first rejection | +1 tick after inputs settle |
Promise.allSettled |
all settle (never rejects) | included as {status:'rejected'}
|
+1 tick after all settle |
Promise.race |
first to settle (fulfill or reject) | rejects if first settles as rejected | +1 tick after first settles |
Promise.any |
first to fulfill | rejects only if all reject (AggregateError) |
+1 tick after first fulfills |
15. await Inside Loops
How you loop over async work changes whether it runs sequentially or in parallel, and
how many microtask ticks it costs. A frequent "why is my code slow / out of order" question.
for…of + await — sequential (one at a time)
async function sequential(urls) {
for (const url of urls) {
const res = await fetch(url); // waits for THIS before starting the next
console.log(res);
}
}
- Each iteration suspends the whole loop until its
awaitsettles. - Total time ≈ sum of all requests (request 2 doesn't start until request 1 finishes).
- Order is preserved. Use this when each step depends on the previous one.
.map + await — parallel (all at once)
async function parallel(urls) {
const promises = urls.map((url) => fetch(url)); // all fetches START immediately
const results = await Promise.all(promises); // then wait for all together
results.forEach((r) => console.log(r));
}
-
.mapfires everyfetchsynchronously (noawaitbetween them), so requests run concurrently. - Total time ≈ the slowest single request, not the sum.
- Use this when the steps are independent.
Common bug:
array.forEach(async (x) => { await ... })does not wait,forEach
ignores the returned promises, so the outer function continues before they settle. Use
for…of(sequential) ormap+Promise.all(parallel) instead.
Tick / ordering example
async function run() {
for (const n of [1, 2, 3]) {
await Promise.resolve();
console.log("loop", n);
}
}
run();
Promise.resolve()
.then(() => console.log("A"))
.then(() => console.log("B"))
.then(() => console.log("C"));
Each await in the loop costs one microtask tick, interleaving with the outer chain:
Output:
loop 1
A
loop 2
B
loop 3
C
Why it interleaves, step by step. Both the async loop and the .then chain feed the
same microtask queue, and each advances only one step per tick, so they take turns.
After the synchronous code runs:
-
run()executes until its firstawait(n=1), which suspends the function and queues the rest of that iteration (console.log("loop", 1)+ continue) as microtask R1. -
Promise.resolve().then(A)queues A. The chained.then(B)/.then(C)are not queued yet, a chained.thenis only scheduled after its previous one runs.
So the queue starts as [R1, A] (R1 first because run() was called before the chain).
| Tick | Runs | Prints | Queues next | Queue after |
|---|---|---|---|---|
| 1 | R1 | loop 1 |
hits next await (n=2) → R2
|
[A, R2] |
| 2 | A | A |
.then resolves → B
|
[R2, B] |
| 3 | R2 | loop 2 |
hits next await (n=3) → R3
|
[B, R3] |
| 4 | B | B |
.then resolves → C
|
[R3, C] |
| 5 | R3 | loop 3 |
loop ends, run() finishes |
[C] |
| 6 | C | C |
— | [] |
Neither side can "run ahead": every await sends the loop's next chunk to the back of
the queue, and every .then only schedules its successor after it runs, so they
alternate perfectly, one loop, one letter, one loop, one letter.
Caveat: this clean 1:1 interleave works because
await Promise.resolve()costs exactly
one tick in modern engines. Twoawaits per iteration would advance the chain two
links per loop step instead.
The loop and the .then chain advance one tick at a time, in lockstep, because both
feed the same microtask queue.
Which to use
| Goal | Pattern |
|---|---|
| Steps depend on each other / run in order |
for…of + await (sequential) |
| Independent steps, want them concurrent |
.map() + await Promise.all(...)
|
| Concurrent but tolerate failures |
.map() + await Promise.allSettled(...)
|
| ❌ Never |
forEach(async …) — it doesn't await |
16. Blocking the Main Thread and Fixing It
The main thread runs your JS and does layout/paint. A single long task blocks
everything, no clicks, no scroll, no paint, until it finishes. A task over 50ms is
officially a "long task" and hurts responsiveness (INP).
The problem
// ❌ Blocks the thread for the whole loop — the page freezes.
function processAll(items) {
for (const item of items) heavyWork(item); // e.g. 100k items
}
While this runs: no frame paints, input handlers queue up, the tab appears frozen.
Fix 1: Chunk the work (yield between batches)
Split the work into slices and yield to the event loop between them so the browser can
paint and handle input:
async function processInChunks(items, chunkSize = 500) {
for (let i = 0; i < items.length; i += chunkSize) {
const slice = items.slice(i, i + chunkSize);
for (const item of slice) heavyWork(item);
await new Promise((r) => setTimeout(r)); // yield: let the browser breathe
}
}
Each setTimeout yield ends the current macrotask, letting microtasks, rendering, and
input run before the next chunk. (MessageChannel is a faster yield than setTimeout.)
Fix 2: requestIdleCallback — run low-priority work when idle
requestIdleCallback((deadline) => {
// Keep working while there is idle time this frame.
while (deadline.timeRemaining() > 0 && queue.length) {
processOne(queue.shift());
}
if (queue.length) requestIdleCallback(/* schedule the rest */);
}, { timeout: 2000 }); // timeout guarantees it eventually runs
-
deadline.timeRemaining()tells you how much idle time is left in the frame (~up to 50ms). - Ideal for non-urgent work: analytics, prefetching, cleanup.
- The
timeoutoption ensures it runs even if the browser never goes idle. -
Not for visual updates (use
requestAnimationFrame) or critical work.
Fix 3: scheduler.postTask() and scheduler.yield() (modern)
The Prioritized Task Scheduling API gives explicit priorities:
// Priorities: "user-blocking" > "user-visible" (default) > "background"
scheduler.postTask(() => doImportantWork(), { priority: "user-blocking" });
scheduler.postTask(() => doAnalytics(), { priority: "background" });
// scheduler.yield() — yield mid-task, then RESUME with priority (better than setTimeout,
// which sends you to the back of the queue).
async function work(items) {
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
if (i % 500 === 0) await scheduler.yield(); // pause, let the browser respond, resume
}
}
Fix 4: isInputPending() — yield only when the user needs the thread
function processWhileFree(items) {
while (items.length) {
if (navigator.scheduling?.isInputPending()) {
// A click/keypress is waiting — yield so the UI stays responsive.
setTimeout(() => processWhileFree(items));
return;
}
heavyWork(items.shift());
}
}
This keeps the thread busy for throughput but bails out the instant input arrives,
balancing speed and responsiveness.
Fix 5: Move heavy work off-thread (Web Worker)
For genuinely CPU-heavy work (parsing, crypto, image processing), get it off the main
thread entirely so the UI never blocks:
const worker = new Worker("heavy.js");
worker.postMessage(bigData);
worker.onmessage = (e) => render(e.data); // main thread only renders the result
Decision guide
| Situation | Tool |
|---|---|
| Big loop on the main thread | Chunk + yield (setTimeout / MessageChannel) |
| Non-urgent background work | requestIdleCallback |
| Need explicit task priorities | scheduler.postTask() |
| Yield mid-task but resume soon | scheduler.yield() |
| Keep working but respect input | isInputPending() |
| CPU-heavy computation | Web Worker (off-thread) |
| Visual/animation updates | requestAnimationFrame |
Golden rule: keep every task short. Break long work into chunks that each finish
under ~50ms, yield between them, and push truly heavy computation to a Worker, so the main
thread is always free to paint and respond to the user.
17. Web Workers and Their Own Event Loop
Everything so far assumed one event loop on the main thread. A Web Worker runs your
code on a separate OS thread, and that thread has its own, completely independent
event loop, its own call stack, its own macrotask queue, and its own microtask queue.
One event loop per thread
flowchart LR
subgraph MAIN["Main thread"]
MCS["Call Stack"] --> MMIC["Microtask Q"] --> MMAC["Macrotask Q"]
end
subgraph WORKER["Worker thread"]
WCS["Call Stack"] --> WMIC["Microtask Q"] --> WMAC["Macrotask Q"]
end
MAIN <-->|postMessage / onmessage| WORKER
Key consequences:
| Fact | Why it matters |
|---|---|
| Separate stack + queues | A worker's setTimeout / promises / loops never touch the main thread's queues, and vice versa. |
| No shared scope | Workers can't touch the DOM, window, or your variables. They share nothing by default. |
| True parallelism | Worker code runs at the same time as the main thread, blocking the worker's loop does not freeze the UI. |
| Communication is async | Data crosses threads only via postMessage, which lands as a macrotask (message event) on the other thread's loop. |
| Messages are copied | Payloads are structured-cloned (deep-copied), not shared, unless you use Transferables or SharedArrayBuffer. |
A worker blocking itself does NOT block the UI
// main.js
const worker = new Worker("worker.js");
worker.postMessage(1_000_000_000); // ask it to do heavy work
worker.onmessage = (e) => console.log("result:", e.data); // macrotask on MAIN loop
console.log("main thread stays responsive");
// worker.js
self.onmessage = (e) => {
let sum = 0;
for (let i = 0; i < e.data; i++) sum += i; // blocks the WORKER's loop only
self.postMessage(sum); // macrotask back on MAIN loop
};
The for loop fully blocks the worker's event loop (its own timers/promises wait), but
the main thread keeps painting and handling input the whole time. That is the entire
point of a worker: move blocking work off the loop that renders the UI.
Cross-thread ordering: postMessage is a macrotask
// Inside the worker:
self.onmessage = () => {
console.log("worker: sync");
Promise.resolve().then(() => console.log("worker: microtask"));
self.postMessage("done"); // does NOT run main's handler now — just queues a task there
console.log("worker: after postMessage");
};
// Worker output: worker: sync → worker: after postMessage → worker: microtask
postMessage doesn't synchronously call the other side, it enqueues a macrotask on the
receiving thread's loop. Each thread still follows the same rule internally: sync → all
microtasks → one macrotask.
Worker types (all get their own loop)
| Type | Scope | Use |
|---|---|---|
Dedicated Worker (new Worker) |
One page | Offload CPU work for a single tab |
Shared Worker (SharedWorker) |
Shared across tabs/windows of same origin | One background loop several tabs talk to |
| Service Worker | Origin-wide, event-driven proxy | Caching, offline, push, background sync |
Mental model: an event loop belongs to a thread, not to the whole app. Each worker is
its own little "single-threaded world" with a private stack and queues; threads stay
isolated and only exchange copied messages that arrive as macrotasks. This is how JS
gets real parallelism without giving up the single-threaded-per-loop model.
More Details:
Get all articles related to system design
Hashtag: SystemDesignWithZeeshanAli
Git: https://github.com/ZeeshanAli-0704/front-end-system-design
Top comments (0)