The budget is 16.7 milliseconds
At 60 Hz the browser has 16.7 ms to produce a frame, and part of that is already spoken for. Miss it and the frame is dropped — one dropped frame is a stutter, several in a row is what everyone calls "janky". On a 120 Hz screen the budget is 8.3 ms.
Everything in this lesson is about doing less inside that window.
The pixel pipeline
Each stage forces the ones after it. Change a width and you pay for layout, paint and composite; change a transform and you pay only for composite — and that part runs on the GPU. This is the whole reason animations should use transform and opacity and nothing else.
| Stage | What it does | Triggered by |
|---|---|---|
| Style | Work out which rules apply | any class or style change |
| Layout | Work out where everything goes | width, height, top, margin, font-size, adding a node |
| Paint | Fill in the pixels | color, background, box-shadow, border-radius |
| Composite | Put the layers on screen | transform, opacity, filter |
Layout thrashing
The browser batches style changes and does layout once, at the end of the frame — unless you ask it a question that needs the answer now. offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle() are all questions like that. Ask one after a write and the browser has to lay the whole page out on the spot.
The fix is never to make the code faster. It is to put all the reads before all the writes.
/* every read after a write forces a synchronous layout — N times */
for (const box of boxes) {
box.style.width = box.offsetWidth + 10 + "px";
}
/* read everything, then write everything — one layout */
const widths = boxes.map((box) => box.offsetWidth);
boxes.forEach((box, i) => { box.style.width = widths[i] + 10 + "px"; });
Doing less per frame
content-visibility: auto— the browser skips layout and paint for off-screen sections entirely. Pair it withcontain-intrinsic-sizeso the scrollbar is still honest.contain: layout paint— promises that nothing inside affects anything outside, so the browser can scope the work.- Virtualise long lists — 10 000 rows in the DOM is slow at every stage; render the forty that are visible.
will-change: transformon the element about to animate — and remove it after. It reserves a layer; leaving it on everything costs memory and can make things worse.requestAnimationFramefor anything visual, neversetInterval. AndResizeObserver/IntersectionObserverinstead of measuring in a scroll handler.
Passive listeners and scrolling
Without passive: true the browser must wait for your handler in case it calls preventDefault(), so scrolling stalls behind your JavaScript. With it, scrolling happens on the compositor and your handler runs alongside. For touchstart, touchmove and wheel it is the difference between a page that follows the finger and one that does not.
window.addEventListener("scroll", onScroll, { passive: true });