JavaScriptAdvanced

The JavaScript Event Loop: Tasks, Microtasks and Rendering

Why does a resolved promise always beat setTimeout(fn, 0)? Learn exactly how the event loop schedules tasks, microtasks and repaints, and how to keep long work from freezing the page.

The event loop drawn as a cycle: run one task, run every microtask, then render a frame
On this page

You already know that await pauses a function without freezing the page. But when exactly does that function resume? Why does a resolved promise always run before setTimeout(fn, 0)? And why can a "Loading…" message you set right before a heavy calculation never appear on screen?

All three questions have the same answer: the event loop. Once you can run it in your head, a whole class of timing bugs, flaky tests and janky interfaces stops being mysterious. This article assumes you're comfortable with promises and async/await; if not, start with Understanding async/await in JavaScript.

The pieces#

JavaScript in the browser runs on one main thread, and that thread does more than run your code. It also handles input and paints the screen. Four things share it:

  • The call stack: the functions running right now. The event loop only steps in when it is empty.
  • The task queue: whole units of work waiting their turn, such as a setTimeout callback, a click handler, or a postMessage from a worker. (Specs call these tasks; you'll also see them called macrotasks.)
  • The microtask queue: small follow-up jobs, mainly promise reactions (.then() callbacks and code after an await), plus queueMicrotask() and MutationObserver callbacks.
  • The rendering steps: requestAnimationFrame callbacks, then style, layout and paint.

One turn of the loop#

Stripped to its essentials, the browser repeats this forever:

event-loop.js (pseudo-code)JavaScript
while (true) {
  const task = taskQueue.takeOldest();
  if (task) run(task);                     // 1. exactly ONE task

  while (microtaskQueue.length > 0) {      // 2. EVERY microtask,
    run(microtaskQueue.shift());           //    including ones added meanwhile
  }

  if (isTimeToRender()) {                  // 3. maybe a frame
    runAnimationFrameCallbacks();
    updateStyleLayoutAndPaint();
  }
}
123 Run one taskRun all microtasksRender a frame the oldest in the task queueuntil the queue is emptyonly when a frame is due setTimeoutpostMessage.then() / awaitqueueMicrotask()MutationObserverrequestAnimationFrame click eventstyle and layoutpaint then repeat, forever
Each turn of the event loop runs one task, then empties the microtask queue, then renders a frame if one is due

The asymmetry is the whole story: the loop takes one task at a time, but drains the microtask queue completely, even if microtasks keep adding more microtasks. Rendering only happens between tasks, and only when the display is ready for a new frame (about every 16.7 ms on a 60 Hz screen).

Your script is a task too

The initial run of a <script> is itself a task. That's why promise callbacks created during it run as soon as the script finishes, before any timer.

Predict the output#

Here's a classic puzzle. Work out the order before reading the answer:

order.jsJavaScript
console.log("script start");

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

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

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

(async () => {
  console.log("async start");
  await null;
  console.log("async after await");
})();

console.log("script end");
OutputText
script start
async start
script end
then 1
microtask
async after await
then 2
timeout

Step by step:

  1. The synchronous code runs first: script start, then async start (an async function runs synchronously up to its first await), then script end.
  2. While that happened, three microtasks were queued in order: then 1, microtask, and the rest of the async function. The timer callback went to the task queue.
  3. The script task ends, so the microtask queue is drained. Running then 1 resolves the next promise in the chain, which queues then 2 at the back of the queue. That's why then 2 comes after async after await.
  4. Only when the microtask queue is empty does the loop pick the next task: the timer.

setTimeout(fn, 0) means "run fn in a future task", not "run it now". Every microtask already queued, and every microtask they queue, runs first.

Microtasks can starve the page#

Because the loop won't leave step 2 until the microtask queue is empty, a microtask that keeps scheduling another one never lets the page render or respond:

JavaScript
function spinForever() {
  Promise.resolve().then(spinForever);   // freezes the tab: rendering never gets a turn
}

Swap the promise for a timer and the page stays responsive, because every timer callback is a separate task and the loop can render between them:

JavaScript
function spinPolitely() {
  setTimeout(spinPolitely, 0);           // one task per call: the browser can paint in between
}

setTimeout(fn, 0) isn't really 0

After timers have been nested five levels deep, browsers clamp the delay to at least 4 ms, and background tabs throttle timers much harder. Never use timers for precise timing.

Why your loading message never shows#

This is the most common real-world event loop bug:

JavaScript
button.addEventListener("click", () => {
  status.textContent = "Crunching numbers…";
  crunchNumbers();                 // synchronous, takes ~2 seconds
  status.textContent = "Done!";
});

Users never see "Crunching numbers…". The DOM was updated, but painting only happens between tasks, and the click handler is one long task. By the time the browser gets to paint, the text already says "Done!". Meanwhile the page ignores every click and keystroke for two seconds.

The fix is to break long work into chunks and yield to the event loop between them. The yield must create a new task: await Promise.resolve() only queues a microtask, so it never lets the browser paint.

yield.jsJavaScript
function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();                              // newer browsers
  }
  return new Promise((resolve) => setTimeout(resolve, 0)); // works everywhere
}

async function processAll(items) {
  let lastYield = performance.now();

  for (const item of items) {
    processItem(item);

    if (performance.now() - lastYield > 50) {   // a "long task" is anything over 50 ms
      await yieldToMain();                      // let the browser paint and handle input
      lastYield = performance.now();
    }
  }
}

Now the loading message appears, clicks are handled, and the work still finishes quickly because it only yields every 50 ms rather than after every item. scheduler.yield() is the better choice where it exists: the browser gives your continuation priority over other waiting tasks, so other code can't keep jumping in ahead of your work.

When chunking isn't enough

If the work itself takes seconds of pure computation (parsing a huge file, image processing), move it to a Web Worker. Workers have their own thread and their own event loop, so the main thread stays free.

requestAnimationFrame: work at the right moment#

requestAnimationFrame callbacks run in step 3, right before the browser paints. That makes them the right place for visual updates: you run exactly once per frame, never twice between paints and never in the middle of one.

JavaScript
function animate(time) {
  box.style.transform = `translateX(${(time / 5) % 600}px)`;
  requestAnimationFrame(animate);       // schedule the next frame
}

requestAnimationFrame(animate);

Driving the same animation with setTimeout(animate, 16) drifts out of sync with the display, so some frames get two updates and others none, which is exactly the stutter users notice.

A subtle one: microtasks between event listeners#

Microtasks run whenever the call stack becomes empty, not only at the end of a task. Look at two listeners on the same button:

JavaScript
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");
});
Triggered by Output
A real user click listener 1, microtask 1, listener 2, microtask 2
button.click() in code listener 1, listener 2, microtask 1, microtask 2

When a user clicks, the browser calls each listener with an empty stack, so microtasks run in between. When your code calls button.click(), your code is still on the stack, so every microtask waits until both listeners have finished. Keep this in mind when a test that simulates clicks behaves differently from the real page.

Node.js adds two extra queues#

Node.js uses the same model (one task at a time, microtasks drained after each callback) but adds two scheduling tools of its own:

order.cjsJavaScript
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));

Run as a CommonJS script, this prints nextTick, then promise, then timeout and immediate in an order that can change from run to run.

  • process.nextTick() callbacks run before promise microtasks. Use it sparingly: recursive nextTick calls starve the loop just like recursive microtasks.
  • setImmediate() runs in a dedicated phase after I/O callbacks. Inside an I/O callback it always fires before a setTimeout(fn, 0); from the main script the order isn't guaranteed.

ES modules

In an ES module (.mjs), promise may print before nextTick, because the module body itself is evaluated from inside the microtask queue. Don't write code that depends on the exact order of nextTick and promises.

Recap#

  • The event loop runs one task, then all microtasks, then renders if a frame is due, and repeats.
  • Promise callbacks, code after await and queueMicrotask() are microtasks: they always run before the next timer, event or paint.
  • A microtask that schedules microtasks forever freezes the page; timers don't, because each one is a separate task.
  • Long synchronous work blocks painting and input. Split it up and yield with setTimeout or scheduler.yield(). await Promise.resolve() does not yield.
  • Use requestAnimationFrame for visual updates and Web Workers for heavy computation.

Next time something runs "in the wrong order", ask yourself: is this a task or a microtask, and what else is in that queue?