C.W.K.
Stream
Lesson 04 of 05 · published

Microtasks vs Macrotasks — The Scheduler's Two Queues

~14 min · async, microtasks, scheduling, event-loop

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Every async thing in Node lives in one of two queues. Knowing which lets you predict order, debug starvation, and stop being surprised when await 'doesn't yield enough.'"

Two Queues, One Loop

The Node event loop maintains a strict separation between two kinds of scheduled work:

  • Macrotasks (a.k.a. tasks) — the six phases of the event loop. Each phase processes its own queue: setTimeout/setInterval callbacks in Timers, I/O callbacks in Poll, setImmediate callbacks in Check, close handlers in Close.
  • Microtasks — a separate queue drained between each macrotask AND between each phase. queueMicrotask(fn), Promise.then handlers, await continuations all go here. Plus a sub-queue for process.nextTick, which drains BEFORE the Promise microtask queue.

The Draining Rule

After every single macrotask, AND between every event loop phase, Node drains the entire microtask queue to completion before moving on. This is why Promise.resolve().then(fn) always runs before any I/O or timer that you scheduled in the same synchronous block — the microqueue gets drained first.

The trap: if your microtasks recursively schedule more microtasks, the loop never progresses past the microqueue. Your server stops responding. This is microtask starvation; it's the async-await version of nextTick starvation from Lesson 3.

The Actual Order

For a single synchronous code block in Node, the schedule is:

  1. The synchronous code itself.
  2. All process.nextTick callbacks (FIFO).
  3. All Promise microtasks — queueMicrotask, .then, await resumes (FIFO).
  4. One macrotask from the next event loop phase (Timers if there's an expired timer, otherwise Poll/Check/etc.).
  5. GOTO 2 (microqueue drains again before next macrotask).

Step 5 is the killer detail. Most developers think "phases, phases, phases, with microtasks somewhere in between." Actually it's "phase, drain microtasks, phase, drain microtasks, ..." — microtasks run vastly more often than phases.

Why await Feels Synchronous

Because the continuation of an await is a microtask. After the line const x = await something(), the rest of the function gets scheduled into the microqueue. The very next thing the runtime does (after the current sync block and any earlier microtasks) is resume your function. There's no "wait for the next tick" — there's "wait until the microqueue is drained," which is usually microseconds.

Critically: a sync block followed by await followed by more sync work doesn't yield to I/O. The post-await sync work runs in the microqueue, and the microqueue blocks the next phase. If you have a CPU-heavy block after an await, you're still blocking the event loop, just from a microtask instead of from the original sync block.

setImmediate vs queueMicrotask — When To Use Which

If you want to give I/O a chance to run, use setImmediate(fn) — it goes in the Check phase, after a Poll iteration, meaning new connections and I/O callbacks fire before fn. If you want to defer work just past the current sync block but BEFORE any I/O, use queueMicrotask(fn) — same priority as a .then.

If you're writing a loop that needs to process big chunks of work without blocking forever, sprinkle setImmediate between chunks — that's how you cooperatively yield to the rest of the system.

Pippa's Confession

For a long time I conflated all async into "the event loop will figure it out." Dad showed me the trace from a real server stall: a chain of 50 .then() handlers each doing tiny CPU work, processed entirely in the microqueue between two poll phases. Total: 80ms with NO I/O accepted during that span. The fix was breaking the chain with one setImmediate(...) in the middle. The microqueue can become a CPU sink even when each handler is "fast." Now when I write a long .then chain or a tight for await loop, I ask: "does this need to yield to I/O somewhere?" Usually yes.

Code

Six schedulers, one tick — verified order·javascript
// Predict the order. Then run it and check yourself.

console.log('1: sync start');

setTimeout(() => console.log('A: setTimeout 0'), 0);
setImmediate(() => console.log('B: setImmediate'));

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

async function quick() {
  await Promise.resolve();
  console.log('F: after await');
}
quick();

console.log('2: sync end');

// Expected output (Node 26):
// 1: sync start
// 2: sync end
// C: nextTick
// D: queueMicrotask
// E: promise.then
// F: after await
// B: setImmediate    (or A first if timer is 'expired enough')
// A: setTimeout 0
Yielding to I/O inside long async loops·javascript
// Microtask starvation in the wild
// This LOOKS innocent but blocks the event loop.
async function processAll(items) {
  for (const item of items) {
    await processOne(item);  // each iteration runs in the microqueue
  }
}

// 10000 items, each fast — but I/O is starved for the whole duration.
// Fix: yield to the loop periodically
async function processAllNice(items) {
  for (let i = 0; i < items.length; i++) {
    await processOne(items[i]);
    if (i % 100 === 0) {
      // Macrotask break — let I/O happen
      await new Promise(r => setImmediate(r));
    }
  }
}

External links

Exercise

Write a tiny HTTP server (createServer) that has one route, /. Inside the handler, run a sync for loop that calls Math.random() 10 million times before responding. Hit it with two concurrent requests using curl. Both wait. Now do the same but break the loop into chunks with await new Promise(r => setImmediate(r)) every 100k iterations. Hit it again with two concurrent requests. They interleave. That's the I/O-yielding superpower made visible.
Hint
The pre-yield version is essentially synchronous CPU work — head-of-line blocking. The yielding version turns sync work into cooperative chunks, letting the event loop accept the second connection and start its work in between chunks. The total throughput drops slightly (overhead per yield), but latency for concurrent requests improves dramatically.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.