Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Part 12 of 15 · ~7 min

The Event Loop: Microtasks vs. Macrotasks

Everything so far has been about mechanism. This chapter is about order — the rules that decide which of several waiting pieces of code runs next, and in what sequence. It is the last mechanism in this reference and the one that explains the timing of everything before it.

Diagram of the JavaScript event loop showing the call stack, Web APIs, the microtask queue, and the macrotask queue

There are two queues, and the difference between them is the whole chapter.

  • Macrotasks (the specification calls them simply tasks) — an input event callback, a setTimeout or setInterval callback, a click, an I/O completion. Everything the browser hands you from the outside world.
  • Microtasks — .then / .catch / .finally callbacks, the continuation of a function after an await, and anything passed to queueMicrotask. In Node, process.nextTick as well, which runs ahead of even the other microtasks.

And here is the rule that answers nearly every async-ordering question you will ever have:

Once the call stack is empty, nothing macrotask-shaped gets to run until the microtask queue is completely drained — and a microtask that queues another microtask just extends the draining, it doesn't get skipped.

Not the first microtask. All of them. Every time.

Our search box makes this concrete, because a single keystroke touches both queues:

inputEl.addEventListener("input", debounce(handleInput, 300));

async function handleInput(event) {
  spinnerEl.hidden = false;
  const results = await fetchResults(event.target.value);
  renderRows(results);
  spinnerEl.hidden = true;
}

The full life of one keypress, in order:

  1. The key goes down. The browser queues the input callback as a macrotask and waits for the stack to be free.
  2. The stack empties, the callback runs. The debounce wrapper cancels a pending timer and schedules a new one 300 milliseconds out. That is all it does. The stack empties again.
  3. Roughly 300 milliseconds later, the timer's callback runs as a macrotask. handleInput starts, runs synchronously as far as the await — so spinnerEl.hidden = false happens here — and then yields.
  4. The stack empties, the microtask queue is drained, and the browser is free. It paints. The spinner appears at this point, and not before.
  5. The response comes back. The Promise settles and the rest of handleInput is queued as a microtask.
  6. As soon as whatever task is currently running finishes, that microtask runs: renderRows mutates the DOM, the spinner is hidden.
  7. The browser paints again, and the dropdown appears.

Every timing question about this widget is answered somewhere in that list. Why the spinner cannot appear before step four. Why the results appear one paint after the response, never in the same instant. Why a slow synchronous function anywhere in the program delays every single one of these steps.

The classic ordering demonstration, in our own vocabulary:

function handleInput(event) {
  console.log("1: handler starts");
  setTimeout(() => console.log("4: debounce timer"), 0);            // macrotask
  Promise.resolve().then(() => console.log("3: cached result"));    // microtask
  console.log("2: handler returns");
}
// 1, 2, 3, 4

1 and 2 run synchronously, before anything else gets a chance. The stack clears, the microtask queue empties out (3), and only then does the macrotask — 4 — get to go, despite asking for zero delay. A setTimeout delay only says when a callback becomes runnable; it says nothing about cutting ahead of whatever's already queued as a microtask.

This has a direct, visible consequence for our search box. Remember the memoisation from chapter five — repeat queries come back from a local cache with no network involved. How you deliver that cached value changes what the user sees:

// Delivered as a microtask: renders in the same frame, no visible flicker
return Promise.resolve(cache.get(key));

// Delivered as a macrotask: the browser gets a paint opportunity in between,
// so the user can see the dropdown empty itself and then refill
return new Promise((resolve) => setTimeout(() => resolve(cache.get(key)), 0));

Identical values, identical code paths, two different experiences — because one of them lands before the next paint and the other lands after it.

Microtasks can starve macrotasks

Read the rule again — the engine will not touch a macrotask until the microtask queue is completely empty — and the failure mode writes itself. A microtask that queues another microtask, forever, means the queue never empties, and nothing else ever runs again.

The pathological minimum:

function spin() {
  Promise.resolve().then(spin);  // queues another microtask, immediately, always
}
spin();
// Timers never fire. Clicks are never delivered. The page never paints again.

You are unlikely to write that by accident. You are much more likely to write this, which is the same thing with a plausible cover story — scoring every page of a large index, one page at a time:

function scoreAllPages(query, page = 0, acc = []) {
  if (page >= PAGE_COUNT) return Promise.resolve(acc);
  return Promise.resolve(scorePage(query, page))          // already-settled: no I/O, no waiting
    .then((rows) => scoreAllPages(query, page + 1, acc.concat(rows)));
}

scorePage is synchronous, so every Promise here is already settled and every .then queues a microtask that queues another microtask. Five hundred pages later the queue has still never been empty. The spinner's tick never fires, the click on the cancel button is never delivered, and nothing paints — and if you are watching a profiler, nothing looks blocked, because technically nothing is. There is no long stack frame anywhere. The thread is busy in a way that the usual "find the slow function" instinct does not catch.

The fix is to give the macrotask queue a turn on purpose, between chunks:

function yieldToTaskQueue() {
  return new Promise((resolve) => setTimeout(resolve, 0)); // a real macrotask boundary
}

async function scoreAllPages(query) {
  const acc = [];
  for (let page = 0; page < PAGE_COUNT; page++) {
    acc.push(...scorePage(query, page));
    if (page % 20 === 0) await yieldToTaskQueue();  // timers, clicks and paint get a turn
  }
  return acc;
}

And one correction that catches almost everybody, because it looks like it should work:

for (let page = 0; page < PAGE_COUNT; page++) {
  acc.push(...scorePage(query, page));
  await Promise.resolve();  // does NOT let the UI breathe
}

await on an already-settled Promise yields to the microtask queue, which is the queue we are already monopolising. Nothing is unblocked; you have simply spelled the starvation loop differently. Letting the browser render requires reaching a macrotask boundary, and only a macrotask can give you one.

Where async/await fits into the queues

Every rule above applies unchanged to async/await, because await is a .then in disguise, right down to which queue it uses:

console.log("1: setting up");

async function loadResults(query) {
  console.log("2: loadResults body, still synchronous");
  await null;                       // yields — everything below is now a microtask
  console.log("4: resumed, as a microtask");
}
loadResults("clos");

console.log("3: caller carries on");
// 1, 2, 3, 4

Two things worth taking from that.

An async function's body runs synchronously up to its first await. Calling one is not "starting a background job" — the first half of it happens right now, on the current stack, inside the caller's task. This is why spinnerEl.hidden = false in handleInput takes effect in step three of the keystroke timeline rather than later.

await yields even when there's nothing to actually wait for. await null hands control back regardless, and whatever comes after it picks back up as a microtask — the same place it would land if you'd written Promise.resolve(null).then(rest) by hand. There's no shortcut around the queue just because the awaited thing was never really pending.

One precision note for anyone timing things to the tick: awaiting an already-settled native Promise resumes on the next microtask tick, but awaiting a plain value or a custom object with a .then method costs additional ticks, because the engine has to wrap and inspect it first. It almost never matters. When you are working out why two interleaved async functions log in an order you did not expect, it is occasionally the whole answer.