ToolPopToolPop
JavaScript · Lesson 12 of 18

The event loop, microtasks vs macrotasks, and the famous output trap

7 min readUpdated 24 Jun 2026

JavaScript runs on one thread. One. That is the whole reason async exists. If a single click handler blocked the thread for 200ms, the page would freeze.

The event loop is the traffic cop that lets a single-threaded language feel non-blocking. Master it and you will never again be surprised by output order. This is the single most-asked question in senior JS interviews.

The pieces on the table

  • Call stack. Where synchronous code runs. One frame at a time.
  • Web APIs (or Node APIs). The browser or Node itself owns these. setTimeout, fetch, DOM events. They run off-thread.
  • Macrotask queue (a.k.a. task queue). Holds setTimeout, setInterval, I/O callbacks, UI events.
  • Microtask queue. Holds Promise.then, queueMicrotask, MutationObserver callbacks.
  • Event loop. A simple loop: if the stack is empty, drain the microtask queue completely, then take one macrotask, then repeat.
Diagram
rendering diagram...
One tick of the event loop

The key word in that diagram is all. Microtasks drain completely. If a microtask queues another microtask, that one runs in the same drain cycle before any macrotask gets a turn.

The famous output trap

Every interviewer asks some version of this. Predict the output.

js
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Step-by-step.

  1. Step 1. console.log("A") runs synchronously. Prints A.
  2. Step 2. setTimeout hands the callback to the Web API. After 0ms it goes into the macrotask queue.
  3. Step 3. Promise.resolve().then(...) queues its callback into the microtask queue.
  4. Step 4. console.log("D") runs synchronously. Prints D. Call stack is now empty.
  5. Step 5. Event loop checks microtasks first. Drains. Prints C.
  6. Step 6. Event loop takes one macrotask. Prints B.

Output: A D C B. Not A D B C. The Promise wins because microtasks always drain before the next macrotask.

Interview trap: setTimeout(fn, 0) is not zero. The 0 is a minimum delay before the task is eligible to run. Browsers also clamp nested timeouts to 4ms after several layers of nesting.

A realistic Swiggy example

js
console.log("Order placed");
 
setTimeout(() => console.log("SMS sent"), 0);
 
fetch("/charge").then(() => {
  console.log("UPI charged");
  Promise.resolve().then(() => console.log("Receipt logged"));
});
 
console.log("Showing loader");

Assuming fetch resolves quickly, the order is roughly.

  1. Order placed (sync).
  2. Showing loader (sync).
  3. Microtasks drain. UPI charged, then the queued Receipt logged in the same drain.
  4. Macrotask runs. SMS sent.

Notice that the SMS line, scheduled with setTimeout(fn, 0), runs AFTER both Promise callbacks. Even though it had a "0" delay. This is the single most common production bug, scheduling work with setTimeout and expecting it to beat a Promise.

Microtasks can starve macrotasks

Because microtasks drain completely, a runaway Promise chain can block the macrotask queue forever.

js
function spin() {
  Promise.resolve().then(spin);
}
spin(); // browser freezes, setTimeout callbacks never fire

Each then queues another microtask. The loop never gets to take a macrotask. The page becomes unresponsive. Useful trap to know exists, never useful to write.

If you genuinely need to yield to rendering, use setTimeout(fn, 0) or requestAnimationFrame. Both go through the macrotask path and give the browser a chance to paint.

Render step, between macrotasks

In browsers, the render step (style, layout, paint) happens between macrotasks, after microtasks have drained. Mutating the DOM inside a tight Promise chain delays the paint. That is why long synchronous loops feel laggy and async chunks feel smooth.

js
// Bad: blocks paint
for (let i = 0; i < 1_000_000; i++) updateCell(i);
 
// Better: yields between chunks
async function paintChunks() {
  for (let i = 0; i < 1_000_000; i++) {
    updateCell(i);
    if (i % 1000 === 0) await new Promise(r => setTimeout(r, 0));
  }
}

The await setTimeout yields a macrotask, lets the browser paint, then resumes.

Quick reference

Call Stack
Where sync JS executes, one frame at a time.
Macrotask
A task from setTimeout, setInterval, I/O, or a UI event. One runs per event loop tick.
Microtask
A callback from Promise.then, queueMicrotask, or MutationObserver. All drain after each macrotask.
Event Loop
The loop that, when the stack is empty, drains microtasks then runs one macrotask.

Predict the output, harder version

js
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve()
  .then(() => console.log(3))
  .then(() => console.log(4));
console.log(5);

Step by step.

  1. Step 1. Sync prints 1.
  2. Step 2. setTimeout queues macrotask 2.
  3. Step 3. First .then queues microtask 3. The second .then is NOT queued yet, it only queues once the first resolves.
  4. Step 4. Sync prints 5. Stack empty.
  5. Step 5. Drain microtasks. 3 runs, which causes the next .then to queue. Still in the same drain cycle. 4 runs.
  6. Step 6. Take one macrotask. 2 runs.

Output: 1 5 3 4 2.

Senior rule: microtasks drain completely before the next macrotask runs. If you can recite that one line and walk through a five-step trace on a whiteboard, you have passed the async section of every senior JS interview I have seen.

Quick quiz, prove you got it

0/3 answered
  1. 1.Predict the output: `console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");`
  2. 2.Which queue does `Promise.resolve().then(fn)` schedule fn on?
  3. 3.Why does `setTimeout(fn, 0)` not run immediately?

Interview insights

When asked "explain the event loop"

  • JS is single-threaded; the event loop coordinates the stack with task queues
  • Web APIs / Node APIs hand work off and notify when done
  • Microtask queue drains fully between every two macrotasks
  • Mention render step (browsers) happens between macrotasks

Tricky questions they will ask

Q.What happens if a microtask schedules another microtask?

A.Both run before the next macrotask. Microtasks added during microtask processing are appended to the SAME microtask queue and drained in the same cycle. This is how you can starve the macrotask queue if you keep recursing.

Q.Does `await x` always pause?
common wrong answer:No, await is a no-op if x is not a promise

A.Yes. Even if x is a resolved value (not a promise), JS wraps it in a microtask and continues. So await ALWAYS yields at least once.

Free tools you can use while you learn

Common questions

Q.What is the difference between microtasks and macrotasks?
A.Microtasks (Promise callbacks, queueMicrotask, MutationObserver) run after the current synchronous code finishes and BEFORE the next macrotask. Macrotasks include setTimeout, setInterval, I/O, and UI events. The microtask queue fully drains between every two macrotasks.
Q.Why does setTimeout(fn, 0) not run immediately?
A.It schedules fn on the macrotask queue. Even with 0 ms delay, fn cannot run until the call stack is empty AND the microtask queue is drained.
Chai0/2 done

Watching quietly. Tap me if you want a tip.

JS Playground
console
// hit run to see output

Try this (0 of 2 done)

  1. 1

    The correct order is A D C B. Confirm by running the snippet.

    hint

    Microtasks (Promise.then) drain before the next macrotask (setTimeout).

    show answer
    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve().then(() => console.log('C'));
    console.log('D');
  2. 2

    Queue a microtask explicitly with queueMicrotask and prove it runs before a setTimeout(fn,0).

    show answer
    console.log('start');
    setTimeout(() => console.log('timeout'), 0);
    queueMicrotask(() => console.log('microtask'));
    console.log('end');