A scroll listener on a long page calls getBoundingClientRect() on about forty elements on every scroll event, to decide which ones are on screen and to reposition a sidebar. Scrolling stutters, and batching the reads and writes only helps a little. What would you replace the measurement with, and why is that cheaper?
answer
- batching reorders, it does not delete
- quantity of reads is the real problem
- let the browser report the geometry
- nothing crossed means no callback
- thresholds cannot express continuous motion
basics
~20 sStop measuring on scroll. Use IntersectionObserver for on-screen decisions and ResizeObserver for size-dependent ones: the browser reports the geometry it already computed and calls you only when something crosses a threshold, so scroll frames where nothing changed cost nothing. A sticky sidebar usually needs no JavaScript at all.
solid answer
~50 sBatching only reorders the work — it still runs forty measurements on every scroll event, on a thread that has to produce a frame. The real win is deleting the measurement. `IntersectionObserver` answers "is this on screen" as part of the browser's own rendering work and invokes the callback only when a target crosses a configured threshold, and the entry it hands you already carries `boundingClientRect` and `intersectionRatio`, so you never read geometry yourself. `ResizeObserver` does the same for "has this box changed size". The sidebar is usually not a JavaScript problem at all — `position: sticky` removes that handler outright. What is left after this is often *no* scroll listener; if one remains, mark it passive and let it do bookkeeping, not measurement. The honest limit is that observers are threshold-based and asynchronous, so a genuinely continuous per-frame value, like a parallax offset, still needs one read per frame.
code
javascript · 17 lines// Before: measures 40 elements on every scroll event
const cards = [...document.querySelectorAll('.card')];
window.addEventListener('scroll', () => {
for (const card of cards) {
const rect = card.getBoundingClientRect();
const visible = rect.top < window.innerHeight && rect.bottom > 0;
card.classList.toggle('in-view', visible);
}
});
// After: the browser reports crossings; no scroll handler, no reads
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
entry.target.classList.toggle('in-view', entry.isIntersecting);
}
});
for (const card of cards) observer.observe(card);go deeper
Know that IntersectionObserver answers "is this element on screen" without a scroll handler, and that a sticky sidebar is usually plain CSS rather than JavaScript.
Explain why the observer is cheaper in mechanism terms — callbacks only on threshold crossings, geometry supplied on the entry, nothing read by your code — and name rootMargin and threshold for what they configure.
Show that you distinguish reordering work from removing it, state the limit honestly for continuous effects, and mention the ResizeObserver write-back loop as a trap you have hit before.
Frame it as a codebase pattern rather than one handler: how you stop scroll-time measurement from being reintroduced, what you standardise on, and how you weigh a rewrite against measured field impact on real hardware.
## Why the handler is the wrong shape, not just the wrong order Scroll events fire at a high rate, and everything in the handler competes with producing the next frame. Forty geometry reads per event is expensive even when they are perfectly batched, because the *quantity* of work is proportional to the number of elements and the frequency of scrolling — neither of which you control. Batching reads and writes is a real technique and it stops the per-iteration round trips, but it cannot make forty measurements free, and it does nothing about the frames where nothing actually changed. That is why the improvement is only partial, and why the next move is architectural: get out of the business of measuring during scroll. ## What IntersectionObserver replaces Any question of the form "is this element visible / has it come into view / how much of it is showing" is what `IntersectionObserver` exists for. You register targets once; the browser evaluates intersections as part of its own rendering work and invokes your callback only when a target crosses one of the thresholds you configured. Two consequences matter for performance: - **Scroll frames where nothing crosses a threshold cost you nothing.** No callback runs, no code executes. A measuring handler pays full price on every one of those frames. - **Your code never reads geometry.** Each entry already carries `boundingClientRect`, `intersectionRect`, `rootBounds` and `intersectionRatio`, computed by the browser at a moment when layout was clean. Reading those properties off the entry cannot force anything. `rootMargin` grows or shrinks the trigger area, which is how you start loading something 200 pixels before it appears; `threshold` accepts a list, so "half visible" and "fully visible" are separate callbacks rather than a computation you write. `root` lets you observe within a scrollable container instead of the viewport. ```js const io = new IntersectionObserver((entries) => { for (const entry of entries) { entry.target.classList.toggle('in-view', entry.isIntersecting); } }, { rootMargin: '100px', threshold: 0.25 }); document.querySelectorAll('.card').forEach((el) => io.observe(el)); ``` ## What ResizeObserver replaces The sibling pattern is code that measures a container on every scroll, resize, or animation tick to keep something in proportion. `ResizeObserver` calls you when an observed box actually changes size and hands you `contentRect` (plus `borderBoxSize` / `contentBoxSize`), so again you stop reading. Two cautions belong in a senior answer. First, it fires when the box changes, which includes changes *you* cause — writing a style inside the callback that resizes the observed element is how pages produce the "ResizeObserver loop completed with undelivered notifications" error, and the fix is to observe one element and mutate a different one, or to guard against writing the same value twice. Second, it is not a substitute for CSS: if the layout can be expressed in the layout engine, express it there. ## What CSS deletes outright Before reaching for any observer, ask whether the effect needs JavaScript at all. A sidebar that should stop at the top of the viewport is `position: sticky` with a `top` offset, and that removes the handler, the measurement and the write together. The cheapest measurement is the one nobody performs. ## When you still have to measure Be explicit about the limit, or the answer sounds like cargo cult. Observers are *event*-shaped: they tell you when something crossed a boundary. Effects that need a continuous value on every frame — a parallax offset, a progress bar bound to exact scroll position, an element that tracks another pixel-for-pixel — cannot be expressed as thresholds. There, keep one read per frame, take it once at a point in the frame where layout is clean, and drive everything from that single value; you have then reduced forty measurements per event to one per frame, which is the achievable win. Also note that scroll position itself (`window.scrollY`) is not the same class of problem as measuring forty elements — the cost you are attacking is per-element geometry, not knowing where the page is. ## Verifying the change Re-record the interaction. Success looks like scroll frames with no scripting entry at all on the frames where nothing crossed a threshold, and rendering work back to once per frame where something did. If the page still janks afterwards, the cost was never in the measurement, and the trace will now show you where it actually is.
- Does marking the scroll listener passive fix any of this?No. `{ passive: true }` promises the browser the handler will not call `preventDefault()`, so scrolling need not wait on it before compositing — valuable, and worth doing, but orthogonal. The handler still runs, still performs forty measurements, and still competes with frame production. Passive prevents a specific class of scroll-blocking delay; it does not reduce the work.
- How would you handle a parallax hero that genuinely needs the scroll offset every frame?Accept that you must read, then minimise it: take one value per frame rather than one per element, take it at a point where layout is already clean, and drive every affected element from that single number by writing transforms only. If the design permits, express it as a scroll-driven CSS animation instead, which keeps the work out of JavaScript entirely.
- Forty elements is small. At what point does replacing measurement stop being worth the rewrite?Judge it from the trace, not the count. If the aggregate time in that handler is a small fraction of the frame and scroll is smooth on representative hardware, leave it — the rewrite has a real cost in review and regression risk. The argument for observers strengthens when the element count is data-driven and can grow, because measurement cost scales with it and the observer's does not.
saying these in an interview costs you the question
- Says batching reads and writes is a complete fix
- Thinks passive listeners reduce the measurement work
- Uses IntersectionObserver for continuous per-frame positions
- Writes styles on the element a ResizeObserver watches
- Reimplements sticky positioning in JavaScript