DEV Community

ZeeshanAli-0704
ZeeshanAli-0704

Posted on

The Event Loop: Microtasks, Macrotasks & Output Prediction

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, and MutationObserver, then you can predict the output of any snippet.

Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Microtasks always beat macrotasks. A Promise.then always 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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
push a() ─► push b() ─► console.log ─► pop b ─► pop a ─► stack empty
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)"]
Enter fullscreen mode Exit fullscreen mode

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
setTimeout ordering applies directly to onmessage.


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.
Enter fullscreen mode Exit fullscreen mode

Apply it mechanically:

console.log("1");                       // sync
setTimeout(() => console.log("2"), 0);  // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");                       // sync
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Ordering facts to memorize:

  • requestAnimationFrame runs 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 rAF callback 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");
Enter fullscreen mode Exit fullscreen mode

Typical output: script startscript endmicrotaskrAFtimeout
(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 ▲
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode
  • 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 rAF callbacks scheduled for a frame receive the same timestamp, 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);
Enter fullscreen mode Exit fullscreen mode

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 → read forces 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";
  });
Enter fullscreen mode Exit fullscreen mode
  • Prefer transform / opacity for 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 modern scheduler.postTask() / scheduler.yield() APIs; use isInputPending() 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.
requestAnimationFrame is 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"));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 setTimeout and 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");
Enter fullscreen mode Exit fullscreen mode

Trace:

  • Sync: start, end (both timeouts + promise chain scheduled).
  • Drain microtasks: promise 1, then its .then queues → promise 2.
  • Macrotask 1: timeout 1.
  • Macrotask 2: timeout 2.

Output:

start
end
promise 1
promise 2
timeout 1
timeout 2
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Output:

sync
outer promise
timeout
inner promise
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

Output: mt 1promisemt 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);
Enter fullscreen mode Exit fullscreen mode

Output: micro 3micro 2micro 1macrotask
(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");
Enter fullscreen mode Exit fullscreen mode

Trace:

  • Sync: 1, schedule timeout, call run()3 (up to await), schedule promise 5, 6.
  • The code after await (4) is queued as a microtask first, then 5.
  • Drain microtasks in FIFO order: 4, then 5.
  • Take one macrotask: 2.

Output:

1
3
6
4
5
2
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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 has A, C.
  • Tick: A runs → queues B. C runs → queues D. Queue now B, D.
  • Tick: B, then D.

Output:

start
end
A
C
B
D
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

First, the guaranteed part:

  • sync prints (synchronous code).
  • promise prints next (microtasks drain before any macrotask).
  • ws message and timeout are 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
:

  1. 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.
  2. A WebSocket message is 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
Enter fullscreen mode Exit fullscreen mode

Flip it: if no message has arrived yet and the 4ms passes first, you'd get timeout
then ws 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");
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Trace:

  • Sync: 1, schedule timer 2 (macrotask), queue microtask M1 (the block), queue microtask M2 (6), 7. → prints 1, 7.
  • Drain microtasks (FIFO: M1, then M2):
    • M1: 3; schedules timer 4 (macrotask, goes after timer 2); queues microtask M3 (5) onto the back. → prints 3. Queue now: [M2, M3].
    • M2: 6.
    • M3: 5.
  • Macrotasks (in enqueue order): timer 2, then timer 4. → prints 2, 4.

Output:

1
7
3
6
5
2
4
Enter fullscreen mode Exit fullscreen mode

Key takeaways:

  • The microtask spawned inside M1 (5) still runs in the same drain, but after the already-queued 6, 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");
Enter fullscreen mode Exit fullscreen mode

Trace:

  • script start
  • foo start (runs synchronously up to the await)
  • script end
  • Microtasks in order queued: foo end, then promise

Output:

script start
foo start
script end
foo end
promise
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: split every async function at each await. Code before the first
await is 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.
  • requestAnimationFrame runs 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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:

  1. process.nextTick queuehighest priority, drained first.
  2. Promise microtask queue — (.then / await / queueMicrotask), drained after the nextTick queue.
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.
Enter fullscreen mode Exit fullscreen mode

process.nextTick beats 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 queuing nextTick can 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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

setTimeout(fn, 0) vs setImmediate — the classic "it depends"

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) beats setTimeout(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 .then fires 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");
Enter fullscreen mode Exit fullscreen mode

Trace (by microtask tick):

  • Sync: sync. Promise.all internally attaches .then to a and b.
  • Tick 1: a then, b then run (direct handlers). Internally, all also records that a and b are now fulfilled, on the second of these it schedules its own resolution.
  • Tick 2: Promise.all's combined promise resolves → its .then runs: all done.

Output:

sync
a then
b then
all done
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Output:

sync
fast: fast
race: fast
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Output:

sync
[
  { status: "fulfilled", value: 1 },
  { status: "rejected", reason: "boom" }
]
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Each iteration suspends the whole loop until its await settles.
  • 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));
}
Enter fullscreen mode Exit fullscreen mode
  • .map fires every fetch synchronously (no await between 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) or map + 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"));
Enter fullscreen mode Exit fullscreen mode

Each await in the loop costs one microtask tick, interleaving with the outer chain:

Output:

loop 1
A
loop 2
B
loop 3
C
Enter fullscreen mode Exit fullscreen mode

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 first await (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 .then is 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. Two awaits 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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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 timeout option 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" });
Enter fullscreen mode Exit fullscreen mode
// 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode
// 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
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

systemdesignwithzeeshanali

Git: https://github.com/ZeeshanAli-0704/front-end-system-design

⬆ Back to Top

Top comments (0)