The budget, in human terms
- At 60fps one frame lasts 16.7ms — and the browser keeps ~6ms for itself. Your script, layout and paint get roughly 10ms.
- A dropped frame isn't the crime; a dropped frame every third scroll is. DevTools paints each over-budget frame red in the FPS graph — look for rhythm, not single spikes.
- Long tasks block input too: a 120ms main-thread task means taps and scrolls queue behind it. Same fix, harsher deadline.
Read the three lanes
Open Performance → record a scroll. You'll see a main-thread lane (purple scripting, green layout, pink paint) and a compositor lane below. The handshake: every time you animate a property the main thread owns — width, height, top, left, box-shadow — the main thread must re-run layout or paint before the compositor can show anything.
If your frame is red in the compositor lane alone, the browser is struggling to rasterise and upload tiles — usually too many backdrop-blurs or giant repaint regions, not your JavaScript.
The transform/opacity-only rule, quantified
// red flags in a scroll trace, in order of cost:
// 1. 'Layout' blocks > 2ms repeating → animating width/top/height
// 2. 'Paint' blocks growing each frame → backdrop-blur on a moving layer
// 3. 'Rasterize' every frame → layer bigger than the viewport
// 4. scripting > 8ms in a scroll → layout-thrash or React re-render
//
// the fix checklist:
// - move the animated element to its own layer (will-change: transform)
// - animate only transform / opacity / filter (perf-tier: yes)
// - replace box-shadow motion with a pre-blurred pseudo-element
// - once the layer exists, REMOVE will-change — it costs memoryThe 8ms habit
Before writing any animation, ask: which lane does this property live in? If the answer is layout or paint, you've spent the budget before the frame started. transform and opacity are the only properties that skip both — that's not a style preference, it's the compositor's contract.