You've built the "load more" trigger for an infinite-scroll feed a dozen times. The rule is always the same: don't fire the fetch on every scroll event — that's dozens of calls a second — fire it once, after the user actually stops.
So you debounce it. Wait 150ms after the last scroll event, then treat that as "stopped," and fetch the next page.
It ships fine. Then someone scrolls with a trackpad, pauses for a beat with their fingers still down, and your "stopped" check fires mid-gesture. The fetch goes out, the list jumps, the user loses their place — while they're still scrolling.
The bug isn't your debounce delay. It's that scroll never tells you when scrolling is actually over. You've been asking it a question it was never built to answer.
The obvious fix, and why it's a guess
Here's the version most of us have shipped:
let timer;
container.addEventListener("scroll", () => {
clearTimeout(timer);
timer = setTimeout(() => {
console.log("probably stopped");
loadNextPage();
}, 150);
});
This works most of the time, because most scrolling really does leave gaps longer than 150ms once it's done. But the delay is a bet, not a fact. Set it too low and any brief pause — a finger resting on the trackpad or screen mid-gesture, a slow drag — trips your timer before the user is actually done. Set it too high and every genuinely-finished scroll sits there for an extra beat before your code reacts, which reads as lag on a "back to top" button or a scroll-spy nav highlight.
There was never a version of this number that was simply correct. It couldn't be — the browser is the only thing that actually knows when a scroll gesture and its momentum are done, and it wasn't telling you.
The event that answers the actual question
It does now. scrollend fires once, on the same target you'd attach scroll to, exactly when the browser considers the scroll position to have finished changing — the user's gesture has ended and there are no more pending position updates (including the tail end of momentum scrolling):
container.addEventListener("scrollend", () => {
console.log("actually stopped");
loadNextPage();
});
No delay to tune. No mid-gesture false positives. It fires on a plain <div> with overflow: auto, on document, and on window for the page itself — wherever you'd have listened for scroll in the first place.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The gotcha: scrollend fires for your own scrolls too
scroll has always fired for programmatic scrolls as well, but a debounced scroll handler rarely moves the scroll position itself, so it almost never mattered. scrollend invites exactly that — "once it stops, snap it into place" — and like scroll, it fires for programmatic scrolling: scrollTo(), scrollBy(), scrollIntoView(), even assigning scrollTop. Anything that moves the scroll position counts, not just a finger or a wheel.
That means this looks reasonable and isn't:
container.addEventListener("scrollend", () => {
container.scrollTo({ top: snapToNearestSection(), behavior: "smooth" });
});
Every correction fires another scrollend, so the handler runs again. If the scroll landed exactly on target, that second call asks for the position it's already at — nothing moves, nothing fires, and it settles. But if the target you compute ever differs from where the scroll actually lands, each correction triggers the next. That's easier to hit than it sounds: in Chrome at 125% display scaling, scrollTop = 35 reads back as 35.2, so a snapToNearestSection built on Math.ceil sees 35.2, rounds up to the next section, and can walk a section or two past where it should have stopped. Snap math that never returns the same target twice is worse — a fast infinite loop or a slow, visible jitter that never settles. The fix is the same guard you'd want for any event you can also trigger yourself:
let correcting = false;
container.addEventListener("scrollend", () => {
if (correcting) return;
const target = snapToNearestSection();
if (Math.abs(target - container.scrollTop) < 1) return; // already there — don't refire
correcting = true;
container.scrollTo({ top: target, behavior: "smooth" });
container.addEventListener("scrollend", () => { correcting = false; }, { once: true });
});
One more edge worth knowing: if a scroll call doesn't actually move the position — you ask it to scroll somewhere it already is — no scrollend fires at all, because nothing changed. That's usually convenient; it's also why the "already there" check above matters more than it looks like it should. A correction that doesn't move anything never fires the scrollend that resets correcting, so the user's next scroll would go uncorrected. The check compares within a pixel rather than with === because scrollTop can be fractional — that 35.2 again — and asking for 35 from there moves nothing.
Why this is a good time to reach for it
scrollend isn't new — Chrome and Firefox have shipped it since 2023. It sat unusable for anything that needed to work in Safari until Safari 26.2, released in December 2025, finally added it. That's the release that completed cross-browser Baseline coverage; before it, "just use scrollend" meant leaving iOS and macOS Safari users with nothing.
If your analytics show a meaningful slice of visitors still on a Safari version from before that release, feature-detect rather than assume:
if ("onscrollend" in window) {
container.addEventListener("scrollend", handleStop);
} else {
// last-resort debounce, only for the browsers that still need it
let timer;
container.addEventListener("scroll", () => {
clearTimeout(timer);
timer = setTimeout(handleStop, 150);
});
}
That's the debounce demoted to what it always should have been: a fallback for older browsers — in practice, Safari before 26.2 — not the default answer everywhere.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
scroll tells you movement is happening. It was never going to tell you when it's done, and no debounce delay was ever going to fix that — it could only ever narrow the guess. scrollend isn't a nicer way to detect the same thing; it's the browser finally exposing the fact you were estimating all along.
Got a debounced scroll handler running in production right now? Check what delay it's using — and ask yourself what it's actually protecting against if you swapped it for scrollend today.
📚 Read next
- I Ripped Out a Carousel Library. CSS Replaced It.
- You're blocking touchmove events to contain scroll.
overscroll-behaviordoes it natively. - Your scroll listener is doing CSS's job
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)