跪拜 Guibai
← Back to the summary

The Event Loop's Hidden Half: Rendering, Microtask Stalls, and the 16.6ms Budget

Event Loop: After reading this, you don't need to read anything else!

Regarding Event Loop, you've probably read many articles already.

Most articles end up with this quiz 👇:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1 4 3 2

You memorize it, pass the interview, and then what? 🤷‍♂️

Then you hit a real performance problem: after a piece of JavaScript logic finishes, the DOM changes on the page are clearly in effect, but the screen doesn't show an update. You're puzzled—the code executed, the DOM changed, why doesn't the page move?

Because code finishing and the browser painting changes to the screen are two completely different things. Between them lies a whole process you've never fully understood.

Today's article won't talk about setTimeout vs Promise interview trivia. I'm going to break down everything that happens in a single browser frame (16.6ms), in precise chronological order. After reading, you'll know:


First, correct a conceptual mistake!

Most people picture the Event Loop like this:

Gemini_Generated_Image_nn3ex5nn3ex5nn3e.jpg

This model is not wrong, but severely incomplete. It only describes the scheduling logic of the JavaScript engine, without touching the browser's rendering pipeline.

And most performance problems you encounter—dropped frames, jank, visual delay—are related to the rendering pipeline, not at all to the execution order of setTimeout vs Promise.


What exactly happens in a browser frame?

The browser's goal is to render 60 frames per second, about 16.6ms per frame. In this 16.6ms, the browser must complete the following steps, strictly in order:

事件循环与渲染流程.png

That's the complete model. Let's break it down step by step 👇.


Step 1: Take one macro task

The Macro Task Queue contains: setTimeout callbacks, setInterval callbacks, user interaction events (click, input), network request callbacks, MessageChannel, etc.

Key point: Each round of the Event Loop takes only one macro task, not the entire queue.

// These two setTimeouts don't execute in the same frame
// They each execute in separate event loop iterations
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);

That's why setTimeout(fn, 0) isn't immediate—it executes in the macro task phase of the next Event Loop iteration, and that next iteration may wait until the current frame's rendering pipeline is fully done.


Step 2: Drain all microtasks

After the macro task finishes, the browser drains all microtasks at once, including any new microtasks that are produced during the draining.

Promise.resolve().then(() => {
  console.log('microtask 1');
  // creates another microtask inside the microtask
  Promise.resolve().then(() => {
    console.log('microtask 2');
  });
});

// Output order: microtask 1 → microtask 2
// Both happen in the same microtask draining phase

There's a performance trap many people don't know: if your microtasks keep generating new microtasks, the browser will stay in this loop forever and never reach the rendering phase.

// This code freezes the page
function loop() {
  Promise.resolve().then(loop);
}
loop();
// Microtasks never finish draining → browser never renders → page freezes

The same logic with setTimeout won't freeze:

// This code does not freeze the page
function loop() {
  setTimeout(loop, 0);
}
loop();
// Each setTimeout goes into the macro task queue
// Only one macro task per loop, rendering is interleaved

Microtasks can block rendering; macro tasks cannot. That's the fundamental performance difference between the two, far more important than which runs first.


Step 3: Decide whether rendering is needed

Not every Event Loop iteration triggers a render. The browser decides whether this frame needs to paint based on several factors:

DOM 批量更新与渲染.png

This explains a common confusion: why consecutive DOM modifications don't trigger multiple repaints.

const el = document.getElementById('box');
el.style.width = '100px';   // modification 1
el.style.height = '200px';  // modification 2
el.style.background = 'red'; // modification 3

// These three changes trigger only one render
// Because they all execute in the same macro task
// The browser waits for the macro task and subsequent microtasks to finish
// then computes style, layout, and paint all at once

This batching mechanism is the browser's built-in performance optimization. You don't need to manually batch DOM operations; the browser does it for you.


Step 4: requestAnimationFrame

If the browser decides this frame needs to render, before calculating style and layout, it executes all queued requestAnimationFrame callbacks first.

requestAnimationFrame(() => {
  // This code runs before rendering
  // DOM or style changes here are painted in this frame
  el.style.transform = `translateX(${position}px)`;
});

That's why animations should use requestAnimationFrame rather than setTimeout:

// setTimeout for animation: unreliable timing, easy to drop frames
function animate() {
  el.style.transform = `translateX(${position++}px)`;
  setTimeout(animate, 16);
}

// requestAnimationFrame for animation: precisely synced to the rendering cadence
function animate() {
  el.style.transform = `translateX(${position++}px)`;
  requestAnimationFrame(animate);
}

One detail: microtasks generated inside a requestAnimationFrame callback are cleared in this step, not deferred to the next round. But a requestAnimationFrame called inside another requestAnimationFrame defers the new callback to the next frame.


Steps 5-8: The rendering pipeline

After requestAnimationFrame finishes, the browser enters the actual rendering phase:

浏览器四阶段渲染流程图.png

These four steps are internal to the browser; JavaScript code cannot directly intervene. But your code can indirectly affect the time these steps take:

// Forcing a synchronous Layout (also called forced reflow)
el.style.width = '100px';     // modify style (only marks layout as needing re-run)
const height = el.offsetHeight; // read layout info → browser is forced to run Layout immediately
el.style.height = height + 'px'; // modify style again → marks layout again

// This write-read-write pattern causes multiple Layout runs in a single macro task
// That's Layout Thrashing, a performance killer

The correct approach is to group all reads together, then all writes together:

// Read first, then write, to avoid Layout Thrashing
const height = el.offsetHeight;  // read
const width = el.offsetWidth;    // read (doesn't trigger extra Layout because the previous Layout result is still valid)

el.style.height = height + 10 + 'px';  // write
el.style.width = width + 10 + 'px';    // write (browser only marks pending, doesn't run Layout immediately)

// After all writes, the browser runs a single Layout in the rendering phase

If you alternate read and write for multiple elements in a loop, the performance cost of Layout Thrashing multiplies:

// Layout Thrashing in a loop: every iteration forces a Layout
const items = document.querySelectorAll('.item');
items.forEach(item => {
  const h = item.offsetHeight;        // read → forced Layout
  item.style.height = h * 2 + 'px';   // write → invalidates Layout
});
// 100 elements = 100 forced Layouts, the page freezes
// Batch reads, then batch writes
const items = document.querySelectorAll('.item');
const heights = Array.from(items).map(item => item.offsetHeight); // batch read (only triggers 1 Layout)

items.forEach((item, i) => {
  item.style.height = heights[i] * 2 + 'px';  // batch write (only marks, doesn't trigger Layout)
});
// Rendering phase runs 1 Layout, 100x better performance

This is why understanding the rendering pipeline is vital for front-end performance optimization—if you don't know the Style → Layout → Paint → Composite chain, you won't know where your code is creating unnecessary computation.


Step 9: requestIdleCallback

If all the above steps are done and the 16.6ms budget for this frame hasn't been exhausted, the browser hands the remaining time to requestIdleCallback:

requestIdleCallback((deadline) => {
  // deadline.timeRemaining() tells you how many milliseconds are left
  // Usually only 1-5ms, used for non-urgent work
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    doSmallTask(tasks.pop());
  }
});

requestIdleCallback is suitable for:

But absolutely do not make DOM modifications inside requestIdleCallback. Because it runs after rendering, changes you make here won't be painted until the next frame. And if your changes trigger a Layout, that Layout cost eats into the next frame's budget.

// Changing DOM inside requestIdleCallback
requestIdleCallback(() => {
  el.style.display = 'block';  // change won't be visible until next frame
  el.textContent = 'loaded';   // user will perceive a delay
});

// Use requestIdleCallback for computation, requestAnimationFrame for rendering
requestIdleCallback(() => {
  const result = heavyCalculation();  // do computation in idle time
  requestAnimationFrame(() => {
    el.textContent = result;  // update DOM before next frame renders
  });
});

A truly deep interview question

Here's an interview problem that almost nobody can answer completely:

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => console.log('3'));
}, 0);

requestAnimationFrame(() => {
  console.log('4');
  Promise.resolve().then(() => console.log('5'));
});

Promise.resolve().then(() => {
  console.log('6');
  requestAnimationFrame(() => console.log('7'));
});

console.log('8');

Using our complete Event Loop model 👇:

事件循环与渲染时序图 (1).png

Final output: 1 → 8 → 6 → 4 → 5 → 7 → 2 → 3

(Note two points: the rAF callback for console.log('7') is registered in the microtask phase, so it's deferred to the next frame. The relative order of 2 and 4 depends on whether the browser triggers a render this round. In most cases rAF runs before the next setTimeout, but that's not guaranteed by the spec.)


How does this knowledge apply in real work?

After all this talk about execution order, in engineering practice, these rules apply 👇:

前端性能三要点.png

The logic behind these three rules comes entirely from the essence of the Event Loop timing model we covered today.

Alright, that's it for today's share!


If you like my articles, also follow my WeChat public account: 【前端技术官】.

Main sharing: Front-end Architecture · AI Programming · Workplace Cognition · Developer Growth

前端技术官-ErpanOmer

Scan the QR code with WeChat to follow 👆

Updates irregularly, no spam, just genuinely useful content.