19 min read

Chapter 1 — The Event Loop Under Pressure

Exact ordering rules, rendering, starvation, long tasks and Node's phases

Prerequisites: FE-10 Parts 7–9 (call stack, task queue, microtask queue, how a promise works). This chapter assumes those and goes further. Course map: Chapter 0. Related: Debugging 08 — Inside the Node process.


How to Read This Chapter

FE-10 gave you the model: one call stack, a task queue, a microtask queue. This chapter is about what happens when that model is under load: code that queues microtasks forever, a CSV import that holds the thread for three seconds, a test that passes locally and fails in CI because two Node callbacks swapped order. In an interview you will be shown a snippet and asked "what does this print?" or "why does this endpoint stop answering?". You should be able to answer both by naming which queue each callback sits in and when that queue is drained.

Every output order in this chapter was run in Node 22 or headless Chrome. If a result depends on timing, the chapter says so.

TermMeaning
Task (macrotask)A unit of work the loop picks from a task queue: a timer callback, an I/O callback, a UI event, a MessageChannel message
MicrotaskA callback in the microtask queue: promise reactions, await continuations, queueMicrotask, MutationObserver
Job queueThe ECMAScript spec's name for where promise jobs go. Hosts (browsers, Node) map it onto their microtask queue
Microtask checkpointThe moment the host drains the microtask queue completely, including microtasks added during the drain
Rendering opportunityA point between tasks where the browser may run requestAnimationFrame callbacks, then style, layout and paint
Long taskA task that holds the main thread for more than 50 ms (the browser's definition). In Node the symptom is event-loop lag
StarvationOne queue never empties, so work in other queues never runs
YieldEnding the current task so the loop can run something else. await alone is not a yield to the loop
PhaseOne stage of Node's loop (timers, poll, check …), each with its own queue

Part 1: The Exact Ordering Rules

Here is the browser loop, simplified only where it does not change ordering:

text
forever:
  task = oldest runnable task from one of the task queues
  run task until the call stack is empty
  microtask checkpoint:
      while the microtask queue is not empty:
          run the oldest microtask        (it may queue more; they run in this same loop)
  if this is a rendering opportunity (roughly once per display frame):
      for each requestAnimationFrame callback: run it, then a microtask checkpoint
      style → layout → paint

Three facts follow from this, and you can derive every ordering puzzle from them:

  1. Sync code first. A task runs to completion. Nothing interrupts it, not even a resolved promise.
  2. All microtasks before the next task. The checkpoint drains the whole queue, and anything queued during the drain joins the same drain.
  3. A .then callback is queued when its promise settles, not when you call .then. So in a chain p.then(a).then(b), b is not in the queue until a has run.
js
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C')).then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
console.log('F');

Output: A F C E D B. C and E are both queued during the sync code. D is only queued once C has run, so it goes behind E. B is a task, so it waits for the whole drain.

The checkpoint runs whenever the stack empties

The checkpoint is not tied to "the end of a task". The HTML spec runs one whenever the JavaScript stack becomes empty, and that includes between two event listeners that the browser itself calls. That gives a classic difference:

js
button.addEventListener('click', () => {
  Promise.resolve().then(() => log.push('micro 1'));
  log.push('listener 1');
});
button.addEventListener('click', () => {
  Promise.resolve().then(() => log.push('micro 2'));
  log.push('listener 2');
});
How the click happensOrder (Chrome)Why
A real user clicklistener 1, micro 1, listener 2, micro 2The browser calls each listener from an empty stack, so a checkpoint runs after each
button.click() from your codelistener 1, listener 2, …your code after click()…, micro 1, micro 2Your code is still on the stack, so no checkpoint until it returns

This is why a unit test that calls .click() can pass while real users hit a bug, or the other way round. Any event dispatched from JavaScript (dispatchEvent, jsdom, Testing Library) behaves like the second row: microtasks queued by the first listener run after all listeners.

Step through it yourself. Predict the output before each step, then compare with the queues.

Loading simulation…

Part 2: How await Desugars

An async function runs synchronously until its first await. At that point it returns a pending promise to its caller and the rest of the body becomes a promise reaction.

js
async function loadWallet(id) {
  const row = await db.get(id);   // ← the function returns here the first time
  return row.balance;
}

// roughly the same as:
function loadWallet(id) {
  return new Promise((resolve, reject) => {
    Promise.resolve(db.get(id)).then(
      (row) => resolve(row.balance),     // the rest of the body
      reject,                            // a rejection is thrown at the await
    );
  });
}

The detail that matters for ordering: await v calls PromiseResolve(v). If v is already a native promise, it is used as-is, so the continuation costs one microtask hop after v settles. If v is a plain value, it is wrapped in an already-resolved promise, so the continuation still runs in a microtask, never synchronously. (Before V8 7.2 in 2018 an await cost three hops; old blog posts show different orders.)

js
async function f() {
  console.log('f1');
  await null;
  console.log('f2');
}
console.log('s');
f();
Promise.resolve().then(() => console.log('p'));
console.log('e');

Output: s f1 e f2 p. f1 is sync. await null queues f2's continuation first, then p is queued, so f2 comes first.

What return costs inside an async function

Returning a promise from an async function is not free. The function's own promise must adopt the returned one, and adoption takes extra hops.

js
async function a() { return Promise.resolve('a'); }       // returns a promise
async function b() { return 'b'; }                        // returns a value
async function c() { return await Promise.resolve('c'); } // awaits, then returns a value

a().then(console.log);
b().then(console.log);
c().then(console.log);
Promise.resolve()
  .then(() => console.log('p1'))
  .then(() => console.log('p2'))
  .then(() => console.log('p3'))
  .then(() => console.log('p4'));

Output: b p1 c p2 a p3 p4.

FormHops before the caller's .then runsWhy
return value1The async promise fulfils at once; the reaction is queued
return await p2One hop to resume after await, one for the caller's reaction
return p3Resolving with a thenable queues a job that calls p.then, which queues another job, then the caller's reaction

You will never tune performance with this. You need it for two things: answering ordering puzzles, and knowing that return await is not redundant inside try: without the await, a rejection from p skips your catch. Chapter 4 covers that trap.

Every await is a point where other code runs. Between the line before an await and the line after it, any number of other microtasks, tasks, requests and event handlers may have run and changed shared state. Chapter 5 builds on exactly this.

Loading simulation…

Part 3: Rendering Opportunities and requestAnimationFrame

The browser paints only between tasks, and only at a rendering opportunity, roughly once per display frame (16.7 ms at 60 Hz). It never paints during a task or during a microtask checkpoint. It may also skip rendering when nothing changed or the tab is hidden.

requestAnimationFrame(cb) queues cb to run in the next rendering opportunity, just before style, layout and paint. That makes it the right place to read layout once and write styles for the frame. In Chrome, a checkpoint runs after each rAF callback:

js
requestAnimationFrame(() => {
  out.push('raf');
  Promise.resolve().then(() => out.push('raf-micro'));
});
requestAnimationFrame(() => out.push('raf2'));
setTimeout(() => out.push('timeout'), 0);
Promise.resolve().then(() => out.push('micro'));
// Chrome: micro, timeout, raf, raf-micro, raf2

micro is first for the usual reason. timeout before raf is typical, not guaranteed: it depends on where in the frame the code ran. Never write code that relies on the order of setTimeout(0) and rAF.

The everyday bug this explains is the spinner that never shows:

js
saveButton.onclick = () => {
  status.textContent = 'Saving…';   // DOM updated, but not painted
  const report = buildReport(rows);  // 800 ms of sync work
  status.textContent = 'Saved';
};

The user never sees "Saving…", because no paint happens until the click task ends, and by then the text says "Saved". Adding await null after the first line does not help: the continuation is a microtask, and microtasks run before any paint. To let the browser paint first, wait for a frame and a task boundary after it:

js
const nextPaint = () =>
  new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));

saveButton.onclick = async () => {
  status.textContent = 'Saving…';
  await nextPaint();              // the frame with "Saving…" is painted here
  const report = buildReport(rows);
  status.textContent = 'Saved';
};

The real fix for 800 ms of work is to chunk it (Part 5) or move it to a Worker; nextPaint only makes sure the spinner appears.


Part 4: Microtask Starvation

The checkpoint drains until the queue is empty. If each microtask queues another, the queue is never empty, so the loop never reaches the next task, timer, I/O callback or paint.

js
const start = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - start} ms`), 0);

let n = 0;
function spin() {
  if (++n < 5_000_000) queueMicrotask(spin);
}
spin();
// Node 22 on a laptop: timer fired after ~220 ms

A 0 ms timer waited 220 ms. In Chrome, 3 million chained microtasks delayed a requestAnimationFrame callback by about 680 ms, so the page froze for that long. Remove the limit and the timer never fires.

Nobody writes queueMicrotask(spin) in production. They write a loop with an await that never waits for anything real:

js
async function drain(queue) {
  while (queue.length) {
    const item = queue.shift();
    await handle(item);   // if handle() never does real I/O, this never yields to the loop
  }
}

If handle is async but only does in-memory work, each await resumes in a microtask. A 100,000-item queue then runs as one long checkpoint: no request is accepted and no timer fires until it finishes. await yields to other microtasks, not to the event loop.

Watch the timer queue fill up while the microtask queue never empties:

Loading simulation…

Part 5: Long Tasks and How to Chunk Them

A long task holds the thread for more than 50 ms. In the browser it delays input handling (a poor INP score). In Node it delays every other request on that process, plus health checks and timers.

#
Main thread
Task queue
Load balancer
1
POST /import starts parsing 40k rows
healthy
1 / 7

The pattern is a time budget: do work until the slice has used its budget, then end the task with a real yield.

js
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function scoreAll(docs, { budgetMs = 10 } = {}) {
  const out = [];
  let sliceStart = performance.now();
  for (const doc of docs) {
    out.push(score(doc));                       // sync, CPU-bound
    if (performance.now() - sliceStart > budgetMs) {
      await yieldToLoop();                      // a new task: timers and I/O run first
      sliceStart = performance.now();
    }
  }
  return out;
}

With a 20 ms setInterval running alongside, the unchunked version fired its first heartbeat after 3.1 s. The chunked version fired every 20 ms throughout, and the total run took about the same time.

What counts as a real yield:

YieldWhereNotes
await setImmediate() from node:timers/promisesNodeRuns in the check phase, after pending I/O
await scheduler.yield()Chromium 129+Resumes ahead of other queued tasks, so your work is not pushed to the back. It lets pending input run between chunks, but in Chrome the continuation also outranks rendering, so frames can still be skipped (try the Starvation tab of the sim above)
await new Promise((r) => setTimeout(r, 0))BothWorks everywhere. Browsers clamp nested timeouts to at least 4 ms after 5 levels
MessageChannel postBrowserNo 4 ms clamp; what React's scheduler uses
await null, await Promise.resolve()BothNot a yield. Microtask only

Two cautions to say out loud in an interview. First, chunking keeps the process responsive but the CPU still does the same work; if the work is large or frequent, move it to a Worker or worker_threads (Chapter 8). Second, each yield is a point where other code runs, so the data you are iterating can change between slices. A chunked loop over this.pendingCharges needs the same care as any other await (Chapter 5).


Part 6: Node's Phases

Node's loop (libuv) is a cycle of phases. Each phase has its own queue of callbacks:

PhaseRunsExamples
timersExpired setTimeout / setInterval callbacksRetry delays, timeouts
pending callbacksSome system I/O callbacks deferred from the last cycleTCP errors such as ECONNREFUSED on some systems
idle, prepareInternal—
pollWait for I/O and run I/O callbacksfs, sockets, incoming HTTP requests
checksetImmediate callbacksWork you want right after the current I/O
close callbacks'close' eventssocket.on('close')

Between every single callback in every phase (since Node 11), Node drains two queues in this order: the process.nextTick queue, then the promise microtask queue, repeating until both are empty.

js
setTimeout(() => {
  console.log('t1');
  Promise.resolve().then(() => console.log('p1'));
  process.nextTick(() => console.log('n1'));
}, 0);
setTimeout(() => console.log('t2'), 0);
// t1 n1 p1 t2

Node 10 and older printed t1 t2 n1 p1, draining only between phases. If you see that order in an old answer, that is why.

nextTick vs promises: it depends on where you are

js
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
Where this runsOutput
Top level of a CommonJS file (.cjs)sync nextTick promise
Top level of an ES module (.mjs, "type": "module")sync promise nextTick
Inside any callback (timer, I/O, event)nextTick promise

The ESM row surprises people. ES module evaluation is itself driven by promises, so your top-level code runs inside a microtask; Node drains the rest of the microtask queue before it gets back to the nextTick queue. Inside a callback, Node's rule applies: nextTick queue first.

Once Node is draining promise microtasks, a nextTick queued from inside one waits until that drain ends:

js
setTimeout(() => {
  Promise.resolve().then(() => console.log('promise A'));
  process.nextTick(() => {
    console.log('tick 1');
    Promise.resolve().then(() => {
      console.log('promise B');
      process.nextTick(() => console.log('tick 3'));
    });
    process.nextTick(() => console.log('tick 2'));
  });
}, 0);
// tick 1, tick 2, promise A, promise B, tick 3

And nextTick can starve the loop exactly like microtasks can. A recursive process.nextTick blocks timers, I/O and promise callbacks until it stops. Use setImmediate for "later, but let I/O in first".

setImmediate vs setTimeout(0)

js
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

At the top level of the main module, the order is not deterministic. setTimeout(0) really means 1 ms. When the loop starts, it enters the timers phase first; whether the 1 ms has passed by then depends on how long startup took. In 40 runs on one laptop: immediate first 39 times, timeout first once.

Inside an I/O callback, the order is deterministic:

js
import { readFile } from 'node:fs';
readFile(import.meta.filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// immediate, timeout (every run)

The I/O callback runs in the poll phase. The next phase is check, so setImmediate runs before the loop wraps around to timers.

Loading simulation…

Spot the Bug

Drill 1: the flaky ordering test

js
// test/notify.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';

test('flushes the audit log before sending the email', async () => {
  const events = [];
  setTimeout(() => events.push('email'), 0);
  setImmediate(() => events.push('audit'));
  await new Promise((r) => setTimeout(r, 20));
  assert.deepEqual(events, ['audit', 'email']);
});

It passes on some runs and fails on others. (Run 15 times here, it failed 10.) What's wrong?

Answer

Bug. The test depends on the order of setTimeout(0) and setImmediate scheduled from outside an I/O callback. That order is not deterministic: it depends on whether 1 ms passed before the loop first checked timers. A loaded CI machine changes the timing.

Interleaving. Fast start: the loop reaches timers before 1 ms has passed, no timer is due, goes on to check → audit, email. Slow start: 1 ms has passed by the first timers phase → email, audit.

Fix. Do not encode order through scheduling primitives. Make the dependency explicit:

js
await audit.flush();
await email.send();

and test the code's actual contract, for example with an injected clock or by awaiting the operations in order.

Say it: "setTimeout zero versus setImmediate is only ordered inside an I/O callback; at the top level it races against process startup time."

Drill 2: the job that never finishes

js
// The worker polls an in-memory status map that a webhook route updates.
const statuses = new Map();          // jobId -> 'running' | 'done'

app.post('/webhooks/render', (req, res) => {
  statuses.set(req.body.jobId, 'done');
  res.sendStatus(204);
});

async function getStatus(jobId) {
  return statuses.get(jobId);
}

export async function waitForRender(jobId) {
  statuses.set(jobId, 'running');
  await startRender(jobId);           // real HTTP call to the render service
  while ((await getStatus(jobId)) !== 'done') {
    // keep checking
  }
  return 'done';
}

After the first call to waitForRender, the whole server stops responding. What's wrong?

Answer

Bug. The loop's only await is on getStatus, an async function that does no I/O. Each iteration resumes in a microtask. The microtask queue never empties, so the loop never reaches the poll phase, so the webhook request that would set 'done' is never read from the socket.

Failure. A deadlock with one thread: the loop waits for the webhook, the webhook waits for the loop. CPU sits at 100%; every other request hangs. (Verified: a timer set to flip the status after 50 ms never ran in a full second of spinning.)

Fix. Wait for a real event, or at least a real delay:

js
import { setTimeout as sleep } from 'node:timers/promises';

while ((await getStatus(jobId)) !== 'done') {
  await sleep(250);           // a timer: the loop runs I/O in between
}

Better: resolve a promise from the webhook handler (Promise.withResolvers() stored by jobId), with a timeout, so no polling happens at all.

Say it: "await on an already-resolved promise only yields to other microtasks. This loop starves the poll phase, so the event it's waiting for can never arrive."

Drill 3: what does it print?

js
// main.mjs
import { readFile } from 'node:fs';

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
process.nextTick(() => console.log('4'));

readFile(import.meta.filename, () => {
  console.log('5');
  setTimeout(() => console.log('6'), 0);
  setImmediate(() => console.log('7'));
  process.nextTick(() => console.log('8'));
  Promise.resolve().then(() => console.log('9'));
});
console.log('10');
Answer

Two answers are possible, and saying so is the point of the drill:

  • 1 10 3 4 2 5 8 9 7 6
  • 1 10 3 4 5 8 9 7 2 6

(In 30 runs on one laptop: the first 10 times, the second 20 times.)

  • 1, 10: sync.
  • 3 before 4: this is an ES module, so top-level code runs inside a microtask and the promise queue drains before the nextTick queue. In a .cjs file you would get 4 3.
  • 5 8 9: the poll phase runs the read callback; right after it, the nextTick queue, then promises.
  • 7 6: from inside an I/O callback, the check phase comes before the next timers phase.
  • 2 floats. It runs in a timers phase. If 1 ms had passed when the loop first checked timers, it prints before 5. If not, the loop moves on, and when the small file read completes in that first poll phase, 2 waits until after 7. It can never land between 5 and 7, and it is always before 6 because it expires first.

Say it: "I place each callback in its queue or phase, and I note the two places where it depends on context: ESM top level, and timers vs immediates."

Drill 4: the chunked loop that still blocks

js
async function reindex(docs) {
  for (let i = 0; i < docs.length; i++) {
    await embedLocally(docs[i]);     // CPU-bound; async only because it returns a promise
    if (i % 100 === 0) await Promise.resolve(); // "let other requests in"
  }
}

A reviewer says "this yields every 100 documents, so it's fine". Is it?

Answer

No. await Promise.resolve() is a microtask hop, not a task boundary. Nothing in the task queue or the poll phase runs until the whole loop is done. The comment describes what the author wanted, not what happens.

Fix. Use a real yield and a time budget instead of a count, so slow documents don't make slices long:

js
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function reindex(docs, budgetMs = 10) {
  let slice = performance.now();
  for (const doc of docs) {
    await embedLocally(doc);
    if (performance.now() - slice > budgetMs) {
      await yieldToLoop();
      slice = performance.now();
    }
  }
}

If embedding is heavy, move it to a worker_threads pool so it does not compete with request handling at all.

Say it: "Resolved-promise awaits don't yield to the event loop. A real yield is setImmediate, setTimeout, or scheduler.yield in the browser."


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If a microtask queues another microtask
  • If an async loop only awaits in-memory work
  • If a handler sets "Saving…" and then runs sync work
  • If a task runs for 3 s on a Node server
  • If you schedule setTimeout(0) and setImmediate at the top level
  • If you schedule both inside an I/O callback
  • If code calls process.nextTick recursively
  • If a test calls button.click()

Review Checklist

When a codebase touches the event loop, look for:

  1. Loops whose only await is on in-memory or cached work. They never yield. Search while ( and for ( near await.
  2. await Promise.resolve() or await null described as "yield". It is not.
  3. Sync CPU work in a request path: JSON.parse of large bodies, CSV parsing, crypto, image work, big .sort(). Measure with monitorEventLoopDelay from node:perf_hooks.
  4. Tests or code that depend on the order of setTimeout(0), setImmediate, requestAnimationFrame.
  5. Recursive process.nextTick or queueMicrotask used for "later". Usually setImmediate is what was meant.
  6. UI state set right before heavy sync work (spinners, "Saving…", disabled buttons) with no frame in between.
  7. Chunked loops over shared mutable state with no thought about what changes between slices.
  8. ESM vs CommonJS differences in any ordering-sensitive startup code using nextTick.

What's Next

You can now say which queue a callback lands in and when it runs. Chapter 2 — Callbacks Done Wrong looks at the oldest way to put code into those queues, and the four ways callback code breaks even when it looks correct.