Infinite Scroll Without a Single Scroll Listener
The old way to detect "user reached the bottom" was a scroll listener full of scrollTop math, wrapped in a throttle function, that still managed to miss on zoomed pages and bouncy mobile scrolling. The better way is to stop measuring scroll entirely.
The sentinel pattern
Put an empty element after your list. Watch it. When it becomes visible, load more:
<ul id="results">...</ul>
<div id="sentinel"></div>
const observer = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
const items = await fetchPage(++page);
appendItems(items);
loading = false;
if (items.length === 0) observer.disconnect(); // no more pages
}, {
rootMargin: '600px', // start loading 600px BEFORE it's visible
});
observer.observe(document.getElementById('sentinel'));
That's the entire mechanism. No throttling, no math, no per-frame work: the browser's compositor already knows what's visible and just tells you.
rootMargin is the UX secret
The rootMargin: '600px' line extends the detection zone beyond the viewport, so the next page starts loading while the user is still 600px away. Done right, most users never see a spinner at all - content is simply there when they arrive. Tune the number to your average fetch time: slow API, bigger margin.
The same tool lazy-loads everything else
Images have loading="lazy" built in, but IntersectionObserver lazy-loads the things that don't: chart libraries, embedded maps, comment sections, video players.
const lazyChart = new IntersectionObserver(([entry], obs) => {
if (entry.isIntersecting) {
import('./chart-heavy.js').then(m => m.render(entry.target));
obs.unobserve(entry.target); // one-shot: stop watching once done
}
}, { rootMargin: '200px' });
document.querySelectorAll('.chart').forEach(el => lazyChart.observe(el));
The unobserve-after-fire pattern matters: an observer that keeps watching finished elements is doing pointless work on every scroll direction change.
Gotchas from production
- A guard flag (the
loadingboolean above) is mandatory - fast scrolling can re-trigger before your fetch resolves and you'll double-load a page. - If the first page of results doesn't fill the viewport, the sentinel is already visible on load and fires immediately. That's usually what you want, but be aware of it.
- Always disconnect when there's nothing left, or the observer fires forever at the page bottom.
Support is universal since 2019. If you're deferring heavier work than a fetch, combine this with requestIdleCallback so preloading never competes with the user's actual interaction.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.