Scroll and resize fire constantly — throttle them or pay for it
scroll and resize are firehose events: a single scroll gesture can fire the handler
dozens of times a second, and each firing runs your callback on the main thread. Put
real work in there — reading layout, updating many elements, recalculating positions —
and you have built a reliable way to drop frames, because you are doing heavy work far
more often than the screen can even repaint. The fix is one of two things: throttle
the handler so it runs at most a few times per second, or — better where it applies —
move the work to a platform API built for exactly this, which does the observing off
the main thread entirely. Doing neither is the default, and the default janks.
Throttle the handler to a sane rate
If you must run work on scroll — updating a progress bar, a sticky header, a scrollspy — throttle it so it fires at most every N milliseconds during the gesture, which is rate-limiting during the burst (distinct from debounce, which waits for the burst to end):
function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) { last = now; fn(...args); } // at most once per interval
};
}
window.addEventListener("scroll", throttle(updateStickyHeader, 100)); // ~10x/sec, smooth
Debounce here would be wrong — the header would only update after the user stops scrolling, not during — which is exactly why throttle is the tool for continuous events.
Better: let the platform observe off the main thread
The modern move is to not listen to scroll or resize at all for the common cases,
because the platform now has observers that do the watching off the main thread and
call you back only when something meaningful changes. IntersectionObserver for “is
this element visible” (lazy-load, infinite scroll, scrollspy) and ResizeObserver for
“did this element’s size change”:
// no scroll handler at all — the browser tells you when the sentinel enters view
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadNextPage(); // fires only when it matters
});
io.observe(document.querySelector("#load-more-sentinel"));
This is strictly better than a throttled scroll handler for visibility work: no main-thread polling, no rate to tune, and it does not fire when nothing changed.
Keep the handler cheap, and read layout carefully
Whichever you use, keep the callback light. The expensive trap inside a scroll handler
is forcing layout — reading offsetTop/getBoundingClientRect mid-scroll makes the
browser reflow synchronously on every call, which janks even a throttled handler. Batch
reads, cache measurements, and defer visual updates to requestAnimationFrame so they
land in sync with the paint. The decision tree is short: for “is it visible / what
size is it,” reach for IntersectionObserver/ResizeObserver; for genuine
scroll-position work that has no observer, throttle the handler and keep it free of
forced layout. Either way, never let a firehose event run heavy work at firehose rate.
The infinite-scroll-list exercise is the canonical IntersectionObserver case, and
debounce-utility builds the sibling technique for the events that want quiet, not a
rate cap.