The Event Loop

Mental model: Run one task to completion, then drain every microtask, then maybe paint. Repeat forever.

Level: intermediate · about 18 minutes

This is the snippet that decides whether you understand async JavaScript. Four lines, and the order surprises almost everybody the first time.

console.log('1 script');

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

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

console.log('2 script');
// -> 1 script
// -> 2 script
// -> 3 promise
// -> 4 timer

Read it, commit to an order out loud, then run it.

Both callbacks were registered before the last log, and the timer was registered before the promise. Yet the promise handler wins. That is not a quirk, it is the whole rule: microtasks run before the next task.

The parts

call stack
the frames currently executing. One per thread, and it must be empty before the loop moves on
heap
where objects live. Not ordered, not part of the scheduling story, just memory
task queue
also called the macrotask or callback queue: timers, events, messages, IO completions
microtask queue
promise reactions, queueMicrotask, mutation observers. Drained completely between tasks
event loop
the loop that takes one task, runs it to completion, then drains the microtasks
render steps
in a browser, style, layout and paint get a chance after the microtasks are empty
                +-------------------+
                |    call stack     |  <-- your code runs here
                +-------------------+
                          ^
       (1) one task       |     (2) then ALL microtasks
   +---------------+      |      +------------------+
   |  task queue   |------+------|  microtask queue |
   | timer, click, |             | .then, await,    |
   | message, IO   |             | queueMicrotask   |
   +---------------+             +------------------+
                          |
                          v  (3) maybe render: style, layout, paint
                    +-----------+
                    |  screen   |
                    +-----------+

One turn, step by step

  1. Run the current task to completion The loop never interrupts your code. A task runs until the call stack is empty, however long that takes. This is why a busy loop freezes everything.
  2. Drain the microtask queue, completely Every queued microtask runs, and microtasks queued by microtasks run in the same drain. The queue must reach zero.
  3. Update the rendering (browser only) Requestanimationframe callbacks, then style, layout and paint. At most once per frame, so about every 16ms at 60Hz.
  4. Pick the next task Back to step one with whatever is at the front of the task queue.
setTimeout(() => console.log('timer'), 0);

Promise.resolve().then(() => {
  console.log('micro 1');
  Promise.resolve().then(() => console.log('micro 2, queued during the drain'));
});

console.log('script');
// -> script
// -> micro 1
// -> micro 2, queued during the drain
// -> timer

A microtask queued from inside a microtask still runs before the timer.

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');

Sync first: a, f. Then the microtask queue in the order things were queued: the first .then handler (c) was queued before queueMicrotask (e). The second .then (d) only gets queued once c returns, so it lands behind e. The timer (b) is a task, so it comes last.

queueMicrotask: the queue without a promise

You do not need a promise to queue a microtask. queueMicrotask(fn) puts fn on the same queue that promise reactions use, without allocating a promise or swallowing anything into a chain.

setTimeout(() => console.log('3 task: setTimeout 0'), 0);
queueMicrotask(() => console.log('2 microtask: queueMicrotask'));
Promise.resolve().then(() => console.log('2 microtask: promise then'));
console.log('1 now: plain call');
// -> 1 now: plain call
// -> 2 microtask: queueMicrotask
// -> 2 microtask: promise then
// -> 3 task: setTimeout 0

Three ways to say "later", in the order they actually run.

PrimitiveQueueRunsUse it for
queueMicrotask(fn)microtaskbefore the next task, after the current oneconsistent ordering without a promise
Promise.resolve().then(fn)microtasksame as above, plus a promise allocationyou already have a chain
awaitmicrotaskresumes the function as a microtaskeverything readable
setTimeout(fn, 0)tasknext turn, after rendering, clamped after nestingyielding to the browser
setInterval(fn, ms)taskrepeatedly, driftingpolling you should probably not do
requestAnimationFrame(fn)before renderonce per frame, paused in background tabsanimation, DOM measurement
requestIdleCallback(fn)idlewhen there is spare time, maybe neveranalytics, prefetch, cleanup
setImmediate(fn) (Node)check phaseafter the current poll phaseyielding in Node
process.nextTick(fn) (Node)before microtasksahead of promise reactionslibrary internals, rarely app code

Rendering and animation frames

A browser wants to paint about 60 times a second, which leaves roughly 16ms per frame for everything: your task, the microtask drain, style, layout and paint. Rendering is not a queue you post to, it is a step the loop takes when the queues let it.

const hasRaf = typeof requestAnimationFrame === 'function';
console.log('requestAnimationFrame available:', hasRaf);

if (hasRaf) {
  setTimeout(() => console.log('timer'), 0);
  requestAnimationFrame(() => console.log('frame, just before paint'));
  queueMicrotask(() => console.log('microtask, before either'));
} else {
  console.log('no frames outside a browser, the queues still behave the same');
}

Feature-detected so it runs in Node too. In a browser the frame callback lands before the timer.

Starvation: the queue that never empties

Because the microtask queue must reach zero before the loop moves on, a microtask that queues another microtask forever means no timer, no event and no paint ever happens again. The tab is alive and completely unresponsive.

let depth = 0;

function greedy() {
  depth += 1;
  if (depth < 5) queueMicrotask(greedy);   // drop this guard and nothing else ever runs
}

setTimeout(() => console.log('the timer finally got a turn at depth', depth), 0);
queueMicrotask(greedy);
console.log('queued');
// -> queued
// -> the timer finally got a turn at depth 5

Bounded on purpose. Remove the counter and the timer below never runs.

The truth about setTimeout(fn, 0)

Zero is a request, not a promise. The delay means "not before this many milliseconds", and then several rules push it later: the current task has to finish, the microtask queue has to drain, rendering may happen, and the platform clamps nested timers.

let n = 0;
let last = Date.now();

function again() {
  const now = Date.now();
  console.log('nesting level', n, 'gap', now - last, 'ms');
  last = now;
  if (++n < 7) setTimeout(again, 0);
}

setTimeout(again, 0);
// -> gaps near 0ms at first, then about 4ms once nesting passes 5

Nested timers. Watch the gap grow once you are five deep.

  • After five levels of nesting, browsers clamp setTimeout(fn, 0) to at least 4ms. Node does something similar (a 1ms floor).
  • In a background tab, timers are throttled to once a second, and after a few minutes even harder.
  • A timer never interrupts a running task, so a 900ms blocking loop turns your 0ms timer into a 900ms timer.
  • setTimeout(fn, 0) therefore means "some time after now, definitely after this task and its microtasks".

Wrong tool: yielding with a microtask

async function walk(items) {
  for (const item of items) {
    handle(item);
    await Promise.resolve();
  }
}
// the thread is never released,
// so the page still cannot paint

Right tool: yielding with a task

const yieldToLoop = () =>
  new Promise((r) => setTimeout(r, 0));

async function walk(items) {
  for (const item of items) {
    handle(item);
    await yieldToLoop();
  }
}
// the loop can render and
// handle input between items

Awaiting a resolved promise only defers to the microtask queue, which is drained inside the same turn. If your goal is to let the browser breathe, you need a task: setTimeout, scheduler.yield() where available, or a message channel.

Interactive visualiser: eventloop. Enable JavaScript to use it.

Try it yourself

Order lab

const log = [];
const mark = (label) => log.push(label);

mark('script start');

setTimeout(() => mark('timer'), 0);

(async () => {
  mark('async body runs synchronously');
  await null;                     // awaiting a non-promise still queues a microtask
  mark('after await');
})();

Promise.resolve().then(() => mark('then'));
queueMicrotask(() => mark('queueMicrotask'));

mark('script end');

setTimeout(() => console.log(log.join('\n')), 10);

Add an await inside the async function and see where the rest of it resumes. Then move the timer to the top and confirm nothing changes.

Starve the loop, safely

const LIMIT = 2000;
let count = 0;
const started = Date.now();

setTimeout(() => console.log('timer ran after', Date.now() - started, 'ms and', count, 'microtasks'), 0);

function spin() {
  if (++count < LIMIT) queueMicrotask(spin);
}
queueMicrotask(spin);

Raise LIMIT to 100000 and watch how long the timer waits. Set it to Infinity only if you enjoy killing tabs.

Exercises

Two ways to wait for nothing

Write nextMicrotask() and nextTask(). Both return a promise that resolves with no value. nextMicrotask() must resolve before any already-scheduled timer callback. nextTask() must resolve after any already-scheduled timer callback.

Simulate the loop

Write simulateLoop(tasks). Each task is a function called with a scheduler { log, microtask, task }. Run tasks from the front of the task queue. After each task returns, drain the microtask queue completely, including microtasks queued during the drain. Microtasks get the same scheduler. Return the array of everything logged, in order.

Check yourself

What does this print?
D B C A — Sync code first (D). Then the microtask queue in queue order: the promise handler was queued before the queueMicrotask call, so B then C. The timer is a task, so A comes last, after the whole microtask queue is empty.
You call setTimeout(fn, 0) and then run a loop that blocks for 500ms. When does fn run?
After about 500ms, once the current task finishes and the microtasks drain — A task never interrupts a running task. The delay is a minimum wait before the callback becomes eligible, not a scheduled execution time. Blocking work pushes every pending timer out by however long you block.
A page hangs at 100% CPU with no error and no long-task warning in the profiler. Which cause fits best?
A recursive microtask that keeps re-queueing itself — The microtask queue must reach zero before the loop continues, so a self-queueing microtask blocks tasks, input and painting forever. Each microtask starts with a clean call stack, so there is no stack overflow to report.
You want to process 10000 rows without freezing the UI. Which yield actually lets the browser paint?
await new Promise((r) => setTimeout(r, 0)) between batches — Microtask yields (the first and third) are drained inside the same turn, so rendering never gets a chance. A task yield ends the turn, which lets the loop render and handle input. A worker is a good option too, but it is not required.

Common mistakes

  • Assuming setTimeout(fn, 0) runs before a promise handler that was registered later.
  • Using await Promise.resolve() to "let the UI update", which never releases the turn.
  • Expecting a 0ms timer to be 0ms after five levels of nesting, or in a background tab.
  • Recursive microtasks, which hang the page with no stack overflow and no error.
  • Treating requestAnimationFrame as a general purpose timer. It is paused when the tab is hidden.

Takeaways

  • One turn is: run one task to completion, drain every microtask, then maybe render.
  • Microtasks (promise reactions, await, queueMicrotask) always run before the next task.
  • The microtask queue is drained to zero, including microtasks added during the drain, which makes starvation possible.
  • setTimeout(fn, 0) means "after this task and its microtasks", clamped to about 4ms once nested.
  • To let the browser paint you must yield with a task, not a microtask.