JavaScript140 min total · 15 parts
JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop
Part 13 of 15 · ~4 min
Where Rendering Fits In
Rendering has been lurking in every chapter — the spinner that never painted, the paint between steps four and five of the keystroke — so let us put it in the loop properly.
A browser's turn through the event loop looks roughly like this: run one macrotask, drain the microtask queue completely, then possibly render — and rendering itself is a sequence: run requestAnimationFrame callbacks, recalculate style, lay out, paint. Then go back for the next macrotask.
The important word is possibly. The browser renders when there is a frame due and something has changed, not after every task. This is why requestAnimationFrame is not a 16-millisecond timer, and why microtask work is described as happening "before the next paint" while macrotask work happens after.
Our dropdown has a bug that this explains and nothing else does. It is supposed to slide down:
#search-results { transition: transform 150ms ease-out; transform: translateY(-8px); opacity: 0; }
#search-results.open { transform: translateY(0); opacity: 1; }
function openDropdown() {
listEl.hidden = false;
listEl.classList.add("open"); // same task as the line above
}
It does not slide. It appears, fully formed, instantly, with no animation whatsoever — and the CSS is correct, which is what makes this so maddening to debug.
The reason is the loop. Both lines run inside one task, and the browser does not recalculate style between two statements. When it finally gets to the rendering step, it computes style once and finds the element already in its final state. A transition interpolates between a previous computed style and a new one; here there has never been a previous one, because no frame was ever rendered with the element visible and shifted up. There is nothing to animate from.
So the fix is to make sure a frame actually happens in the starting state:
function openDropdown() {
listEl.hidden = false; // 1. the starting state now exists in the DOM
requestAnimationFrame(() => { // 2. runs just before the next paint — still too early
requestAnimationFrame(() => { // 3. runs before the frame AFTER that one
listEl.classList.add("open"); // 4. the starting state has now been painted — animate
});
});
}
The doubled requestAnimationFrame is not superstition, though it is very often written as though it were. A requestAnimationFrame callback runs before the paint of its frame, so at that point the starting state still has not reached the screen. The second one runs before the following frame, by which time it has. Two frames, one for the start state and one to begin moving from it.
requestAnimationFrame is the right tool for the other visual piece of the dropdown too — the result count that counts up rather than snapping:
function animateCount(from, to, duration) {
let startTime = null; // closed over by `frame` — chapter four, again
function frame(timestamp) { // rAF passes a high-resolution timestamp
if (startTime === null) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
countEl.textContent = `${Math.round(from + (to - from) * progress)} results`;
if (progress < 1) requestAnimationFrame(frame); // ask for the next frame
}
requestAnimationFrame(frame);
}
Notice that the progress is computed from the timestamp the browser hands in, not from a frame counter. That is the difference between an animation that takes duration milliseconds on every machine and one that takes duration on a 60Hz display, half that on a 120Hz one, and longer than promised whenever a frame gets dropped. Driving the same loop with setTimeout gives you neither the timestamp nor any relationship to the paint schedule, which is where visible jank comes from.
Two more things that follow from rendering being a step in the loop.
Reading layout mid-loop forces the browser to catch up early. Sizing every row against the dropdown's width looks harmless and is not:
rows.forEach((row) => {
row.style.width = `${listEl.offsetWidth}px`; // write, then read — layout is forced, every pass
});
Each write invalidates layout; each offsetWidth read demands an up-to-date answer, so the browser recomputes layout synchronously, once per row. Read everything first, then write everything:
const width = listEl.offsetWidth; // one read
rows.forEach((row) => { row.style.width = `${width}px`; }); // then only writes
requestAnimationFrame stops in a hidden tab. Background the page and the browser simply stops asking for frames, so your animation pauses and resumes cleanly and costs nothing while nobody is looking. setInterval has no such courtesy — it keeps firing, throttled but alive, which is exactly the difference the next chapter is about.