Skip to content

Event Loop: Call Stack, Microtasks & Macrotasks

intermediate25 min read

The Code That Exposes Your Mental Model

setTimeout(() => console.log('A'), 0);
queueMicrotask(() => {
  console.log('B');
  queueMicrotask(() => console.log('C'));
});
Promise.resolve().then(() => console.log('D'));
console.log('E');

If you said E, B, D, C, A — you're close but wrong. The output is E, B, C, D, A... actually, it's E, B, D, C, A. Wait — which is it?

The answer is E, B, D, C, A. But here's the thing: the fact that you had to pause reveals the gap. You're reasoning by pattern matching, not by running the actual algorithm in your head. By the end of this article, you'll have that algorithm — and you'll never second-guess these again.

The Call Stack: A LIFO Machine

You've probably heard "call stack" a thousand times, but let's make sure the mental model is airtight. The call stack is where JavaScript tracks what function is currently executing and what called it. It's a strict LIFO (Last In, First Out) structure — the most recently pushed frame is the first to be popped.

Every time the engine calls a function, it pushes a new execution context onto the stack. When that function returns (or throws), the context is popped. The engine always executes whatever is on top — no multitasking, no jumping around.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4);
Execution Trace
Call
printSquare(4) is called
Stack: [printSquare]
Call
printSquare calls square(4)
Stack: [printSquare, square]
Call
square calls multiply(4, 4)
Stack: [printSquare, square, multiply]
Return
multiply returns 16, popped from stack
Stack: [printSquare, square]
Return
square returns 16, popped from stack
Stack: [printSquare]
Call
printSquare calls console.log(16)
Stack: [printSquare, console.log]
Return
console.log returns, popped
Stack: [printSquare]
Return
printSquare returns, popped
Stack: []

When the stack is empty, the engine is basically idle. And that's exactly when the event loop steps in — it grabs the next piece of work and feeds it onto the stack.

Stack overflow is literal

The call stack has a finite size (varies by engine — roughly 10,000–25,000 frames in V8). Infinite recursion without a base case fills the stack and throws RangeError: Maximum call stack size exceeded. This is not a memory issue — it's a structural limit on nested execution contexts.

Quiz
What happens when you call a function inside another function?

The Event Loop Algorithm

Alright, this is the part that ties everything together. The HTML specification defines the event loop in Section 8.1.7. Stripped of the spec language, the core loop looks like this:

while (true) {
  // 1. Pick the oldest task from a task queue that has tasks
  task = pickTask();

  if (task) {
    // 2. Run it — pushes frames onto the call stack
    execute(task);
  }

  // 3. Microtask checkpoint: drain ALL microtasks
  while (microtaskQueue.length > 0) {
    execute(microtaskQueue.shift());
    // If executing a microtask enqueues more microtasks,
    // they're drained in THIS same loop
  }

  // 4. Rendering opportunity (~16.6ms for 60Hz display)
  if (shouldRender()) {
    runResizeAndScrollHandlers();
    runAnimationFrameCallbacks();     // rAF
    styleRecalc();
    layout();
    paint();
    composite();
  }

  // 5. If idle: requestIdleCallback runs here
}

Seems straightforward, right? But three things trip people up constantly:

  1. Task selection is not FIFO across all queues. The browser has multiple task queues (input events, timers, networking) and chooses which queue to dequeue from. User input events get priority over timers in most browsers.

  2. The microtask checkpoint drains recursively. If a microtask schedules another microtask, the new one also runs before the loop continues. This is both powerful and dangerous.

  3. Rendering doesn't happen every iteration. The browser skips render steps if nothing changed visually or if the display hasn't refreshed (it targets the monitor's refresh rate, typically 60Hz).

Quiz
The browser has three pending setTimeout callbacks and one pending click handler. In what order does the event loop typically process them?

Microtask Queue: The VIP Lane

Think of the microtask queue as the "skip the line" pass at an amusement park. It processes work that is a reaction to something that just happened in the current execution. It runs after every task and drains completely — including microtasks enqueued by other microtasks. And that "completely" part? That's where things get interesting.

Microtask Sources

  • Promise.then() / catch() / finally() callbacks
  • queueMicrotask(fn) — explicit microtask scheduling
  • MutationObserver callbacks
  • process.nextTick(fn) in Node.js (with higher priority — see below)

Macrotask (Task) Sources

  • setTimeout / setInterval callbacks
  • User interaction events (click, keydown, scroll)
  • MessageChannel.onmessage / postMessage
  • I/O completion callbacks
  • setImmediate (Node.js only)

Here's the rule you need to internalize: all microtasks drain before the next task runs. No exceptions. No partial draining. The checkpoint loop runs until the microtask queue is bone-dry.

setTimeout(() => console.log('task 1'), 0);
setTimeout(() => console.log('task 2'), 0);

queueMicrotask(() => {
  console.log('micro 1');
  queueMicrotask(() => console.log('micro 2'));
});

Output: micro 1, micro 2, task 1, task 2.

Both microtasks — including micro 2, which was scheduled by micro 1 — run before either setTimeout callback gets a turn. The checkpoint doesn't stop until the queue is empty. This catches a lot of people off guard.

Quiz
A microtask callback schedules another microtask. When does the second microtask run?

process.nextTick: The Queue Before the Queue

So microtasks are the VIP lane. But in Node.js, there's something even more VIP. process.nextTick schedules a callback that runs before all other microtasks. It has its own internal queue that drains first:

Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));

Output in Node.js: nextTick, promise, queueMicrotask.

The draining order between phases in Node.js is:

1. Drain ALL process.nextTick callbacks (recursively)
2. Drain ALL Promise/queueMicrotask callbacks (recursively)
3. Proceed to next event loop phase

nextTick was added before Promises existed in JavaScript. It served the need for "run this after the current operation but before any I/O." Today, queueMicrotask covers most of those use cases with cross-platform compatibility. We'll go much deeper on this in the next article.

Common Trap

process.nextTick isn't really about the "next tick" of the event loop. It runs BEFORE the next tick — at the end of the current operation. The name is misleading. setImmediate, despite its name, actually runs later (in the check phase). The naming is backwards: nextTick is more immediate than setImmediate.

Quiz
In Node.js, what is the execution order of these three callbacks?

Rendering Between Tasks

Now let's talk about pixels. The render step in the event loop algorithm is the browser's opportunity to update what the user actually sees. It runs approximately every 16.6ms on a 60Hz display, and the order within the render step matters:

  1. Resize/scroll event handlers
  2. requestAnimationFrame callbacks — your last chance to mutate the DOM before paint
  3. Style recalculation — compute the cascade
  4. Layout — compute geometry
  5. Paint — record draw operations
  6. Composite — combine layers, send to GPU

And here's the part that surprises most people: microtasks drain before this render step. This means:

// Both style changes happen before any rendering
element.style.color = 'red';
Promise.resolve().then(() => {
  element.style.color = 'blue';
});
// The user only ever sees blue

The user never sees red. Not even for a single frame. The microtask runs after the current task but before the render step. By the time the browser paints, the color is already blue.

requestAnimationFrame and the microtask checkpoint

rAF callbacks run during the render step, not as regular tasks. But after each rAF callback executes, the browser performs a microtask checkpoint. This means a Promise scheduled inside a rAF callback runs before the next rAF callback and before paint. This is spec-mandated behavior that's useful for coordinating async work with animation frames.

Quiz
You set element.style.opacity = '0.5' inside a Promise.then callback. When does the user see the change?

Microtask Starvation: The Infinite Checkpoint

This is the part that burns people in production. Because the microtask checkpoint drains recursively, a microtask that always schedules another microtask creates an infinite loop:

function forever() {
  queueMicrotask(forever);
}
forever();
// The event loop NEVER advances past the microtask checkpoint
// No macrotasks run. No rendering. The page freezes.

This is microtask starvation. The event loop is technically alive (it's running microtasks), but it can never reach the next task or the render step. The page becomes completely unresponsive.

And this isn't some theoretical edge case. It happens in real production apps when:

  • A Promise chain creates an unbounded recursive .then() loop
  • A MutationObserver callback triggers a DOM change that fires the same observer
  • Library code accidentally creates a microtask cycle during error handling

The scary part? The browser has no built-in defense against this. Unlike long tasks (which the browser can at least detect and report via Long Tasks API), microtask starvation is silent — the event loop is technically running, just never progressing.

No safety net

Unlike setTimeout (which has a minimum 4ms delay after nesting level 5), microtasks have zero throttling. A recursive microtask loop runs as fast as the CPU allows, consuming 100% of a core while the page is frozen. The only "escape" is the user closing the tab.

Quiz
What happens if a microtask always schedules another microtask?

Putting It Together: Complex Ordering

Okay, you've got the individual pieces. Now let's see if you can run the full algorithm in your head. Step through this code and predict the output before clicking Play:

Final output: 1, 8, 4, 7, 2, 3, 6, 5.

See the pattern? Synchronous code runs first. Then microtasks drain completely (including those added by earlier microtasks within the same checkpoint). Then macrotasks run one at a time, each followed by a full microtask drain. Once you see it, you can't unsee it.

Interview Question: The Nested Promise Trap

This one shows up in senior-level interviews all the time, and it's trickier than it looks.

Interview Question

What is the output?

Promise.resolve().then(() => {
  console.log('A');
  Promise.resolve().then(() => console.log('B'));
}).then(() => console.log('C'));

Promise.resolve().then(() => console.log('D'));

Answer: A, D, B, C

The first .then(log A) and the standalone .then(log D) both enter the microtask queue. During the checkpoint, A runs first (it was registered first), scheduling B as a microtask. Then D runs (it was already queued). Now the queue has B and CB from the inner Promise, and C from the chained .then() (which was scheduled when A's callback returned, resolving the intermediate promise). B was enqueued before C, so: A, D, B, C.

The trap: .then() chaining creates sequential microtasks, not nested ones. C doesn't run right after A — it runs after the intermediate Promise resolves, which enqueues C at the back of the microtask queue.

Challenge: Full Ordering

Challenge: Mixed Async Ordering

Try to solve it before peeking at the answer.

javascript
console.log('start');

setTimeout(() => console.log('T1'), 0);

queueMicrotask(() => {
console.log('M1');
queueMicrotask(() => console.log('M2'));
});

Promise.resolve()
.then(() => console.log('P1'))
.then(() => console.log('P2'));

setTimeout(() => {
console.log('T2');
queueMicrotask(() => console.log('M3'));
}, 0);

console.log('end');

Key Rules

Key Rules
  1. 1The call stack is LIFO. Functions execute on top, pop on return. The event loop only feeds a new task onto the stack when it's empty.
  2. 2The event loop algorithm: pick one task → execute → drain ALL microtasks (recursively) → maybe render → repeat. This order is defined by the HTML spec.
  3. 3Microtask sources: Promise.then, queueMicrotask, MutationObserver. Macrotask sources: setTimeout, setInterval, user events, MessageChannel, I/O.
  4. 4Microtask starvation is real: a recursive microtask loop blocks all tasks and rendering with no browser-level safety net.
  5. 5process.nextTick (Node.js) has its own queue that drains before Promise microtasks. Prefer queueMicrotask in new code for cross-platform consistency.
  6. 6Rendering happens during the render step, after microtask drain. DOM changes in microtasks are never painted until the render step — the user sees the final state, not intermediate values.
1/2