Debouncing Scroll-Driven Layout Reads
A scroll handler that calls getBoundingClientRect() or reads offsetTop forces a synchronous layout on every event, and scroll fires far more often than once per frame β so a single parallax or sticky-header handler can flush layout dozens of times per frame. Batching reads into requestAnimationFrame, or replacing them with IntersectionObserver, removes the cost. This builds on Scroll and Input Performance, part of Compositing and GPU Acceleration.
Why Reading Geometry in a Scroll Handler Is Expensive
Properties like getBoundingClientRect(), offsetTop, scrollHeight, and clientWidth must return a value that reflects all pending DOM and style changes. If anything has been mutated since the last layout, the browser must run layout right now to compute a fresh answer β a forced synchronous layout. Inside a scroll handler this is doubly bad: the handler also tends to write a style (move a badge, set a transform), and the next event reads again, creating a read-write-read pattern that invalidates and re-flushes layout every iteration. The browser fires scroll at input frequency (often 60β120Hz, sometimes coalesced but not guaranteed), so the flushes pile up against the 16.6ms budget.
Minimal Reproduction
// β Forces synchronous layout on every scroll event
window.addEventListener('scroll', () => {
const rect = banner.getBoundingClientRect() // read β flush pending layout
progress.style.width = `${rect.top}px` // write β invalidates layout
const h = sidebar.offsetHeight // read again β flush AGAIN
sidebar.style.transform = `translateY(${h / 2}px)`
})
Each event flushes layout at least twice. With a high-rate trackpad this fires 100+ times a second, so the main thread spends most of every frame inside Layout.
The Trace Signature
[Frame] Budget: 16.6ms | Actual: 41.2ms β DROPPED
ββ Main thread
ββ Event: scroll (handler)
β ββ Recalculate Style (2.1ms)
β ββ Layout (9.8ms) β forced by getBoundingClientRect
β ββ Layout (8.4ms) β forced AGAIN by offsetHeight after a write
ββ Event: scroll (handler) β same frame, fired again
ββ Layout (8.9ms)
ββ Layout (7.6ms)
Four Layout entries in one frame, all marked with the purple βforced reflowβ warning triangle in DevTools β the classic layout-thrashing signature.
Fix 1: Batch Reads in requestAnimationFrame
Cache the scroll position synchronously (reading window.scrollY does not force layout β it is a cheap compositor-known value), then do all geometry work once per frame inside requestAnimationFrame, separating reads from writes.
// β
One layout pass per frame; reads batched before writes
let ticking = false
let lastY = 0
window.addEventListener('scroll', () => {
lastY = window.scrollY // cheap: does not force layout
if (!ticking) {
requestAnimationFrame(update) // run at most once per frame
ticking = true
}
}, { passive: true })
function update() {
// READ phase β all measurements together
const bannerTop = banner.getBoundingClientRect().top
const sidebarH = sidebar.offsetHeight
// WRITE phase β all mutations together, no interleaved reads
progress.style.width = `${bannerTop}px`
sidebar.style.transform = `translateY(${sidebarH / 2}px)`
ticking = false
}
The { passive: true } flag keeps the scroll itself on the compositor (see passive listeners for smooth scroll), and the read/write split is the same discipline described in how to batch DOM reads and writes to prevent thrashing.
Fix 2: Replace the Read Entirely with IntersectionObserver
If you only need to know whether an element crossed a threshold β sticky headers, lazy reveals, βscrolled past heroβ flags β IntersectionObserver reports it off the main thread with zero forced layout.
// β
No scroll handler, no getBoundingClientRect, no forced layout
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
header.classList.toggle('is-stuck', !entry.isIntersecting)
}
},
{ rootMargin: '0px', threshold: 0 },
)
observer.observe(sentinel) // a zero-height element at the trigger point
The browser computes intersections during its own layout pass and delivers the callback asynchronously, so it never adds a synchronous flush to a scroll event.
Verification
// Catch any remaining forced reflow inside scroll handlers
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration > 50) console.warn(`Long task ${e.duration.toFixed(1)}ms during scroll`)
}
}).observe({ type: 'longtask', buffered: true })
| Check | Target | How measured |
|---|---|---|
Layout events per scroll frame |
β€ 1 | Performance panel, forced-reflow markers |
| Forced reflow warnings | 0 | DevTools purple triangle on Layout |
| Scroll handler duration | < 4ms | Handler entry under scroll event |
| INP / scroll smoothness | < 200ms, no dropped frames | Event Timing + Frame track |
A passing trace shows at most one Layout per frame and no forced-reflow markers inside the scroll handler. For the underlying mechanism and DevTools workflow see Forced Synchronous Layouts, and for the read/write batching pattern in depth see how to batch DOM reads and writes to prevent thrashing.