JS · Performance · AsyncSep 15, 2026 · 11 min read

Browser event loops

What the render stage of the browser event loop actually does, and the four ways code accidentally stops the page from painting.

A browser tab runs your JavaScript, handles clicks, and draws pixels on one thread. The event loop decides which of those gets handled, and in what order.

Most explanations of it stop at an ordering puzzle:

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

The output is A, D, C, B, because the loop runs them in this order:

  • log A
  • schedule a task: the setTimeout callback
  • schedule a microtask: the .then callback
  • log D
  • drain the microtasks, so C
  • run the next task, so B

However, this leaves out where rendering sits in the loop, and what has to happen before the browser is allowed to do it.

The three parts of every cycle

The loop repeats forever, and each cycle runs the same three parts in the same order.

TEXT
┌─► run one task          (your JS, start to finish)
│   drain all microtasks  (every .then, await, queueMicrotask, until empty)
│   maybe render          (only if a frame is due)
└──────┘  next cycle

A task is one unit of work the browser picked up:

  • a click handler
  • a setTimeout callback
  • a <script> executing
  • a network event firing

The loop runs it to completion. Nothing else on that thread happens while it runs, which is the whole reason a while loop freezes a page.

Microtasks are a separate, smaller queue that drains between tasks. That drain is the checkpoint: once a task ends, the loop empties this queue before moving on, including microtasks added while it is emptying.

Render is last, and it does not always run. The browser repaints when a frame is due, which is tied to your display's refresh rate: about every 16.7ms on a 60Hz screen, every 8.3ms at 120Hz. If a task ends and no frame is due yet, the loop picks up the next task.

So the browser cannot paint in the middle of your task, or in the middle of your microtask drain.

Fig. 1 — One cycle of the event loop: where a running task leaves its marks, and when the render step reads them Open diagram in a new tab ↗

What "render" actually means

Render is a short pipeline, and most of it is usually skipped.

TEXT
JavaScript      your code mutates the DOM and CSSOM
Style           work out which CSS rules apply, compute final values
Layout          work out the geometry: position and size of every box
Paint           produce the drawing commands: text, colors, borders, shadows
Composite       assemble the painted layers into the frame you see

The browser runs only the stages your change invalidated.

What you changedWhat the browser re-renders
el.textContentstyle, layout, paint, composite
el.style.colorstyle, paint, composite
el.style.transformstyle, composite

Changing text moves boxes, so geometry has to be recomputed and everything downstream reruns. Changing a color moves nothing, so layout is skipped. Changing a transform touches neither geometry nor the drawing commands, so the browser reuses the layer it already painted and only reassembles the frame, which is why transform-based animation stays smooth when width-based animation does not.

Fig. 2 — Which stages of the rendering pipeline each kind of change makes the browser rerun Open diagram in a new tab ↗

Setting textContent is a DOM write

The DOM is an in-memory representation of a page's structure, not the page itself. Writing to it is an ordinary JavaScript operation, so it takes effect immediately and synchronously:

JavaScript
p.textContent = i;

Once that line returns, the node holds the new value, and any code that reads it back gets the new value:

JavaScript
p.textContent = "500000";
console.log(p.textContent);   // "500000", right now

None of the rendering pipeline has run. The write marked the node dirty; style, layout, paint and composite come after the task, and the task is still running.

So a DOM write goes through two separate moments:

MomentWhat happensWhen
Writenode value changes, node marked dirtyimmediately, synchronously
Renderstyle, layout, paint, compositeafter the task, and only when a frame is due

Which explains the counter that counts but never updates:

JavaScript
for (let i = 1; i <= 200000; i++) p.textContent = i;

I ran it: 200,000 writes, and zero frames drawn while it ran. Reading p.textContent mid-loop returns the current number every time, so the DOM is keeping up perfectly. The loop is one task, render is behind it, and the user watches a frozen page and then sees 200000 appear.

The DOM is a data structure. The screen is the output of a pipeline that runs over it after your JavaScript has finished executing. DOM writes are immediate, and ending the task frees the pipeline to proceed, which is why splitting the loop across tasks fixes the counter.

Microtask chains

Splitting 300ms of work into 150 pieces of 2ms each leaves three ways to reschedule the next piece: as a microtask, as a task, or as a plain function call.

JavaScript
function step() {
  burn(2);                          // busy-wait 2ms
  if (++n < 150) {
    Promise.resolve().then(step);   // microtask
    // setTimeout(step, 0);         // task
    // step();                      // plain recursion
  }
}
Rescheduled asFrames drawn
microtask0
task118
plain recursion1

The microtask version drew fewer frames than the synchronous one. Both are, as far as the browser is concerned, a single 300ms task.

Draining runs until the queue is empty, and anything queued while it runs is handled by the same pass. A callback that schedules its successor from inside the checkpoint keeps it from ever ending, and render sits directly behind it.

Everything that lands in that queue behaves this way:

  • .then callbacks
  • queueMicrotask
  • MutationObserver callbacks
  • the continuation of every await

await has the most reach in real code, since a microtask continuation is not a yield:

JavaScript
for (const row of rows) {
  render(row);
  await Promise.resolve();   // still one task; the browser gets no frame
}

If the awaited value is already settled, that loop never yields: each iteration adds a fresh microtask, and the checkpoint never closes.

The checkpoint is the last moment before a frame, which makes it the place to collapse work. Mutate a dozen things synchronously, schedule one flush with queueMicrotask, and the browser sees a single batched update while it still has not painted. Use it to combine work before a frame, never to make room for one.

When setTimeout(fn, 0) takes 4ms

The delay argument specifies when the callback becomes eligible, not when it runs, so the real execution time is the delay plus however long the thread stays busy.

Nesting also raises the floor on that delay. Chain setTimeout so that each callback schedules the next, and the seventh callback is the first one to run 4ms late instead of immediately:

JavaScript
function tick() {
  setTimeout(tick, 0);   // each callback schedules the next
}

// how late each callback ran, against its own scheduling time:
// 0, 0, 0, 0, 0, 0, 4.5, 4.2, 5.1, 5.0, 5.1, 4.9

The HTML spec's timer initialisation steps carry the clamp: "If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4." The check reads the scheduling task's own nesting level, so a level-6 task is the first one that schedules a clamped timer, which makes the seventh callback the first one to actually run late.

What counts as nesting catches people out. Twelve sibling timers scheduled from one ordinary task all fired within 0.3ms of each other. No clamp at all.

JavaScript
// breadth: twelve of these from one task, no clamp anywhere
setTimeout(a, 0);
setTimeout(b, 0);
setTimeout(c, 0);

The clamp tracks the depth of timer-created tasks, not how many timers exist, and that catches most chunking code: any self-rescheduling loop is a chain by construction even though it reads as flat.

Fig. 3 — Sibling timers against a chain of timers, and where the 4ms nesting clamp begins Open diagram in a new tab ↗

Start the wait before the work

The natural way to write a chunk is to do the work, then schedule the next one. Every chunk then takes 6ms of work followed by a 4ms wait, because the clamp starts counting from the moment the timer is scheduled and that moment is now the end of the chunk. The thread sits idle for those 4ms with nothing queued to run.

JavaScript
function step() {
  burn(6);               // do 6ms of work
  setTimeout(step, 0);   // only now does the 4ms wait begin
}

Move the timer above the work and the wait elapses underneath work you had to do anyway.

JavaScript
function step() {
  setTimeout(step, 0);   // start the wait now
  burn(6);               // do 6ms of work while it elapses
}

The way I think about it is calling an Uber. Get ready first, then call it, then stand at the door for four minutes: you leave after everything gets ready plus four minutes. Call it first, then get ready: the car arrives while you are still putting your shoes on, and you leave as soon as you are ready.

Over 60 chunks of 6ms, that reorder takes 626ms down to 365ms.

Fig. 4 — Two timelines to scale: the timer's delay running after each chunk, versus underneath it Open diagram in a new tab ↗

Which way of yielding lets a frame through

Since ending the task frees the browser to render, the next question is how to end it. The three common ways are not interchangeable. Each run below does the same 400ms of work, varying how big each chunk is before yielding:

Chunk sizeMessageChannelscheduler.yield()
2ms484
5ms484
10ms404
20ms204

Both finish the work in about 400ms, so the counts are directly comparable, and 48 is every frame a 120Hz display can show in that window.

MessageChannel behaves the way chunking is supposed to: smaller chunks, more frames, up to the ceiling. It is a task source with no clamp on it, so it reaches the render stage reliably and adds no delay. React's scheduler still reaches for it ahead of setTimeout for this reason: it tries setImmediate, then MessageChannel, and only falls back to setTimeout when neither exists.

scheduler.yield() does not move. Four frames whether you yield every 2ms or every 20ms. Its continuation is scheduled ahead of the render stage every time, so chunk size makes no difference. By design, it puts you back at the front of the queue so the work you interrupted resumes promptly instead of losing its place to every task that arrived meanwhile. It protects input responsiveness, which INP measures, and gives up the frame to do it.

setTimeout has no column, because its frame count is not comparable: the clamp stretches the same 400ms of work to 1361ms at 2ms chunks, and a longer window fits more frames regardless of how well it is yielding. It does reach the render stage reliably. It just takes far longer to get there, and the smaller your chunks the worse that gets.

So the choice follows from what you are unblocking. Use scheduler.yield() when a click has to feel instant and the work merely has to finish. Use MessageChannel when something on screen has to keep moving while the work runs, like a progress bar or a streaming response. Use setTimeout when neither of those is under pressure.

The question to ask of chunking code

The question I ask of chunking code now is whether it ends its task before the next frame is due. Being async is not the test: await is async and drew zero frames.

Rendering happens between tasks and nowhere else. The microtask chain never ends the task, the 200,000 DOM writes never end the task, the clamp is the delay ending it adds, and the three yields differ only in how they end it.

A Performance trace shows this directly, and it is the fastest way to check your own code. The microtask version is one unbroken 300ms task bar with no frames underneath it. The setTimeout version is 150 short bars with a frame after about four out of every five. If you cannot see gaps between your task bars, you have not yielded, whatever the code says it is doing.