Ch. 1 · JavaScript

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readadvanced

JavaScript runs your code on a single thread, yet a page can fetch data, run timers and respond to clicks at the same time. The event loop is the scheduler that makes this work, and a “predict the output” question built from a few promises and timeouts is the standard way interviewers check whether you understand it. The rules are short. This note states them precisely, applies them to progressively harder snippets, and ends with the failure mode most candidates never mention: starving the loop.

The moving parts

  • Call stack. Where synchronous code runs. A call pushes a frame, a return pops it. JavaScript runs to completion: nothing else can execute until the stack is empty.
  • Task queue. Callbacks for timers (setTimeout, setInterval), I/O, UI events like clicks, and postMessage/MessageChannel messages. Running the initial script is itself a task. Browsers keep several task queues and may prioritize some (user input, for instance), but within one queue tasks run in the order they were added.
  • Microtask queue. Promise reactions (then, catch, finally callbacks, and the code after an await), queueMicrotask callbacks, and MutationObserver callbacks.
  • The host. Timers and network requests are tracked by the browser (or libuv in Node) outside JavaScript. When one is ready, the host queues a task.

“Macrotask” is informal vocabulary; the HTML spec just says task. Interviewers use both, so it is worth knowing they mean the same thing.

setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('then'));
console.log('sync');
// → sync, queueMicrotask, then, timeout
JavaScript

setTimeout(fn, 0) means “queue a task after at least 0 ms”, not “run now”. Browsers also clamp deeply nested timers to a minimum of about 4 ms.

One turn of the loop

  1. Take the oldest task from a task queue and run it until the call stack is empty.
  2. Run microtasks until the microtask queue is empty, including microtasks queued while draining.
  3. If it is time to update the screen (typically once per display frame, about 16.7 ms at 60 Hz), run requestAnimationFrame callbacks, then recalculate style and layout, then paint.
  4. Repeat.

The microtask drain in step 2 is not limited to the end of a task. It happens whenever the call stack empties after a callback, for example between the individual requestAnimationFrame callbacks in step 3.

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// → A D C B
JavaScript

Three consequences come up constantly. A zero-delay timer always runs after every microtask that is already queued, and after the current script, which is why the classic setTimeout loop logs only once the loop is done. A long-running task blocks both input handling and rendering. And requestAnimationFrame is the right place for visual updates because it runs just before paint; its ordering relative to a setTimeout depends on where in the frame you are, so never rely on it.

Interview tip

Say it out loud: “synchronous code first, then all microtasks, then one macrotask.” Then add “the browser may render between tasks” to show you know where paint fits.

User clicks vs .click()

That “whenever the stack empties” rule produces the most subtle ordering in the browser. Take a button with two listeners:

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 1'));
  console.log('listener 1');
});
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 2'));
  console.log('listener 2');
});

// Real user click: listener 1, microtask 1, listener 2, microtask 2
// button.click():  listener 1, listener 2, microtask 1, microtask 2
JavaScript

On a real click the browser calls each listener from an empty stack, so microtasks drain in between. With button.click() your script is still on the stack while both listeners run, so the microtasks wait until it finishes.

async/await is microtasks in disguise

An async function runs synchronously until its first await. At that point it pauses, and the rest of the function is scheduled as a microtask once the awaited value settles. Even await null pauses.

async function main() {
  console.log('1: before await');
  await null;
  console.log('3: after await');
}

main();
console.log('2: sync, after main()');
// → 1 2 3
JavaScript

For scheduling purposes, awaiting a native promise behaves like attaching a then:

const getUser = () => Promise.resolve({ name: 'Ada' });

async function loadA() {
  console.log('start A');
  const user = await getUser();
  console.log('got', user.name);
}

// Roughly what the engine does:
function loadB() {
  console.log('start B');
  return Promise.resolve(getUser()).then((user) => {
    console.log('got', user.name);
  });
}

loadA();
loadB();
console.log('sync end');
// → start A, start B, sync end, got Ada, got Ada
JavaScript

The real mechanism suspends and later resumes the same function call, locals intact, and turns thrown errors into a rejected promise. The timing matches the sketch, though: one microtask per await on a native promise.

Predict the output

Work each one out before reading the answer. All outputs below were produced by Node 22; browsers give the same results.

Puzzle 1: async functions and a chain

console.log('script start');

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

async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');
}
async function bar() {
  console.log('bar');
}

foo();

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

console.log('script end');
JavaScript

Answer: script start, foo start, bar, script end, foo end, promise 1, promise 2, timeout. bar runs synchronously inside foo. The await queues “foo end” before “promise 1” is queued. “promise 2” is only queued once “promise 1” has run, and the timer waits for the whole drain.

Puzzle 2: timers and microtasks interleaved

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

setTimeout(() => {
  console.log('T2');
  Promise.resolve().then(() => console.log('M inside T2'));
}, 0);

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

Promise.resolve().then(() => {
  console.log('M1');
  setTimeout(() => console.log('T from M1'), 0);
});
JavaScript

Answer: M1, T1, T2, M inside T2, T3, T from M1. Microtasks drain after each task, so “M inside T2” beats T3. The timer created inside M1 was scheduled after the other three, so it runs last. (Node has matched browsers here since version 11.)

Puzzle 3: the executor is synchronous

const p = new Promise((resolve) => {
  console.log('executor');
  resolve('value');
  console.log('after resolve');
});

p.then((v) => console.log('then:', v));

queueMicrotask(() => console.log('microtask'));

console.log('sync');
JavaScript

Answer: executor, after resolve, sync, then: value, microtask. The executor runs immediately, and resolve does not stop it. Calling then on an already-resolved promise queues the callback right away, before queueMicrotask runs.

Puzzle 4: returning a promise

async function returnsPromise() {
  return Promise.resolve('async result');
}

returnsPromise().then(console.log);

Promise.resolve()
  .then(() => console.log('tick 1'))
  .then(() => console.log('tick 2'))
  .then(() => console.log('tick 3'));
JavaScript

Answer: tick 1, tick 2, async result, tick 3. Resolving the async function’s promise with another promise costs two extra microtasks: one to call that promise’s then, one for it to report back. Return a plain value instead and “async result” logs first.

Note

Node adds its own queue: process.nextTick callbacks. In a CommonJS script they run before promise callbacks. In an ES module the order flips, because the module body is itself evaluated asynchronously and the promise queue drains first. Don’t build logic on that difference; just recognize it.

Microtask starvation

Because the loop drains the microtask queue completely before it moves on, a microtask that always queues another one never lets it move on. No timers fire, no input is handled, nothing is painted. The page freezes exactly as if you had written while (true).

// Don't run this: the tab freezes
function forever() {
  Promise.resolve().then(forever);
}
forever();
JavaScript

A bounded version shows the mechanism safely. The timer was scheduled first, yet it waits for all million microtasks:

let count = 0;

setTimeout(() => console.log(`timeout ran after ${count} microtasks`), 0);

function spin() {
  count += 1;
  if (count < 1_000_000) queueMicrotask(spin); // a task can't get in between
}
spin();
// → timeout ran after 1000000 microtasks
JavaScript

Swap queueMicrotask(spin) for setTimeout(spin, 0) and the log reports a count of 1: each setTimeout is a new task, so the waiting timer gets its turn after the first call. That is the principle behind splitting long work into chunks:

function processInChunks(items, handle, chunkSize = 1000) {
  return new Promise((resolve) => {
    let i = 0;
    function runChunk() {
      const end = Math.min(i + chunkSize, items.length);
      for (; i < end; i++) handle(items[i]);
      if (i < items.length) setTimeout(runChunk, 0); // new task: input and paint can run
      else resolve();
    }
    runChunk();
  });
}
JavaScript

Browsers clamp timers nested more than a few levels deep to about 4 ms, so production schedulers often yield through a MessageChannel message instead, which is also a task but without the clamp.

Gotcha

await inside a loop does not yield to the browser. for (...) { await null; work(); } resumes every iteration as a microtask, so it starves rendering just like the spin example. To yield, await a task: await new Promise((r) => setTimeout(r, 0)), or scheduler.yield() where it is supported.

The interview answer

“JavaScript runs on one thread with a call stack, and asynchronous work comes back through queues. The task queue holds things like timers, I/O and UI events; the microtask queue holds promise callbacks, the continuation after an await, queueMicrotask and MutationObserver callbacks. The event loop takes one task, runs it to completion, then drains the microtask queue completely, including anything queued along the way. Only then can the browser run requestAnimationFrame callbacks, render, and pick the next task.

So for any output question: synchronous logs first, then microtasks in the order they were queued, then timers. An async function runs synchronously until its first await, and everything after it is a microtask. The flip side is starvation: an endless chain of microtasks blocks timers, input and paint, so for long work I yield with a task, such as setTimeout or scheduler.yield(), not a promise.”

More in JavaScript

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · easy

Hoisting, the TDZ and var vs let vs const

What the engine sets up before your code runs, why var reads as undefined while let throws, how the temporal dead zone works, and why const doesn't mean immutable.

~6 min readread →
esc