How to Implement Infinite Scroll in Vanilla JavaScript
Implement infinite scroll in vanilla JavaScript with Intersection Observer, sentinel elements, pagination, loading guards, and accessibility fallbacks.
Implement infinite scroll in vanilla JavaScript with the Intersection Observer API: place a sentinel element at the bottom of your list, observe it, and fetch the next page of data each time it enters the viewport.
If you’ve built one of these before, you know the failure mode: you flick the scrollbar a little too hard and the same ten items land in the list three times over. Getting the basic mechanics working takes about ten minutes, and getting them to survive a real user takes the rest of the afternoon. This replaces the old scroll-event-plus-getBoundingClientRect approach, which runs position math on every scroll tick. This guide builds one complete, runnable feed, with real fetch, pagination, and DOM append, and then covers the four production gotchas (double-fetching, never stopping, error handling, and prefetch timing) plus the accessibility fallbacks that separate a demo from shippable code.
Key Takeaways
- Use
IntersectionObserver, not scroll events: a scroll listener fires continuously on the main thread and forces manual position math, while the observer runs a callback only when the target actually crosses the viewport. IntersectionObserverhas been Baseline across all modern browsers since March 2019, so infinite scroll needs no polyfill today.- Guard every fetch with a boolean flag so a fast scroll can’t fire several overlapping requests before the first one resolves.
- Stop when the API returns a short or empty page. Call
observer.disconnect()and hide the sentinel, or the observer keeps re-requesting pages that no longer exist. - Pair infinite scroll with a visible “Load more” button: it’s the keyboard, screen-reader, and no-JavaScript fallback all at once.
Why does IntersectionObserver beat scroll events?
Use IntersectionObserver instead of a scroll listener because it reports visibility asynchronously through a callback that fires only when your target crosses the viewport, instead of running on every scroll frame. The old pattern attaches a scroll handler and calls getBoundingClientRect() on each tick to compute whether the list bottom is near. That is layout-reading math on the main thread, running far more often than you need, and it is a well-known source of scroll jank.
scroll + getBoundingClientRect() | IntersectionObserver | |
|---|---|---|
| Fires | On every scroll frame | Only when the target crosses the viewport |
| Position math | Manual, in your code | Handled by the browser |
| Threading | Synchronous on main thread | Delivered asynchronously |
| Polyfill needed | n/a | No (Baseline) |
No polyfill is required. MDN marks the API as Baseline Widely available, with support in every major browser stretching back to March 2019, so older advice that recommends a polyfill (and cites Chrome-51-era support) is out of date. One exception: don’t reach for trackVisibility by default, because MDN still lists that occlusion-detection property as experimental with limited availability.
What is the sentinel pattern?
Discover how at OpenReplay.com.
The sentinel pattern places a single marker element at the bottom of the list; when the observer reports that the sentinel has entered the viewport, you fetch the next page and append it. The sentinel is just an empty element after your last item, and you never need to re-pick it, because appending new items keeps pushing it further down.
The three moving parts:
- Construct the observer:
new IntersectionObserver(callback, options). - Start watching:
observer.observe(sentinel). - In the callback, check
entry.isIntersectingand load the next page when it’strue.
Iterate the entries array rather than reading entries[0]. The IntersectionObserver() constructor reference warns against assuming any particular entry count, because one run of your callback can carry several crossings at once.
A complete infinite scroll example in vanilla JavaScript
Below is a full working implementation against JSONPlaceholder, a free mock REST API that runs on JSON Server with LowDB behind it. Its /posts endpoint holds 100 records and accepts _page and _limit query parameters, returning the requested slice as a plain array. That gives you a finite dataset, which is convenient for demonstrating what happens when the data runs out.
The markup: a list, a fallback button, a sentinel, and a live-region status line.
<main>
<ul id="list" aria-label="Posts"></ul>
<button id="load-more" type="button">Load more</button>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
</main>
The script wires the observer to the sentinel and fetches one page per intersection:
const LIMIT = 10;
let page = 1;
let loading = false; // guard against overlapping requests
let done = false; // stop at end of data
const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");
async function fetchPosts(page) {
const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function render(posts) {
const frag = document.createDocumentFragment();
for (const post of posts) {
const li = document.createElement("li");
li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
frag.appendChild(li);
}
list.appendChild(frag);
}
async function loadNextPage() {
if (loading || done) return;
loading = true;
status.textContent = "Loading…";
try {
const posts = await fetchPosts(page);
render(posts);
page += 1;
if (posts.length < LIMIT) { // short/empty page = no more data
done = true;
observer.disconnect();
loadMoreBtn.hidden = true;
status.textContent = "You've reached the end.";
} else {
status.textContent = "";
}
} catch (err) {
status.textContent = "Could not load posts. Tap Load more to retry.";
console.error(err);
} finally {
loading = false;
}
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage();
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);
Every request goes through loadNextPage, so the observer callback, the button click, and the initial DOMContentLoaded load all share the same guard and stop logic.
The four gotchas that separate a demo from shippable code
Most tutorials stop at “it appends data.” These four fixes are what make it survive real users.
| Symptom | Cause | Fix |
|---|---|---|
| Duplicate requests on fast scroll | No request guard | if (loading) return; boolean flag |
| List never stops, re-requests empty pages | No end detection | if (posts.length < LIMIT) observer.disconnect() |
| Errors vanish silently | No fetch error path | Check res.ok, try/catch, surface a retry |
| Visible pause at the bottom | rootMargin: "0px" | rootMargin: "200px" to prefetch early |
Guard against double-fetching. A fast flick can fire the callback several times before the first await resolves. The loading flag makes every extra call return early until the in-flight request settles in the finally block. You can also unobserve the sentinel during the request and re-observe afterwards. Just don’t confuse unobserve (one target) with disconnect (all targets).
Stop at the end of data. With a finite source, keep requesting and you’ll spam pages that don’t exist. Detect a page shorter than LIMIT, the same end-of-data signal used in Prismatic’s paginated-API loop tutorial, which breaks out of its loop as soon as a request comes back with an empty array. Then call disconnect() and hide the sentinel and the button.
Prefer threshold: 0 with rootMargin. Setting rootMargin: "200px" starts the next fetch roughly 200 pixels before the user reaches the bottom, removing the visible stall. Combine it with threshold: 0, not 1.0: a sentinel taller than the viewport may never be 100% visible, so a full-visibility threshold can silently fail to fire.
Infinite-scroll bugs are timing- and scroll-velocity-dependent, so a careful local scroll rarely reproduces them. Watching real sessions through a tool like session replay is one way to surface the class of failure that stays invisible in a quick test: duplicate requests on a fast flick, or a list that never stops.
Accessibility and the “Load more” fallback
Always pair infinite scroll with a visible “Load more” button: it’s the keyboard and screen-reader fallback, the no-JavaScript fallback, and often the only way a user can pause the stream to reach the footer. Endless auto-loading content traps assistive-technology users, buries footer links behind content that keeps growing, and breaks back-button scroll restoration when the user returns to a position that no longer exists in the DOM.
Three concrete steps, all in the code above:
- Announce loading state through a live region:
<p role="status" aria-live="polite">lets screen readers hear “Loading…” and “You’ve reached the end.” - Keep the button as a real, focusable control so it works when the observer never fires or JavaScript is disabled.
- Mark the sentinel
aria-hidden="true". It’s a mechanism, not content, and shouldn’t reach the accessibility tree.
If a feed’s footer genuinely matters (contact links, legal, pagination for deep links), consider whether a “Load more” button alone is the better pattern and reserve auto-loading for content where an endless stream is the point.
Infinite scroll in vanilla JavaScript comes down to one durable idea: observe a sentinel, fetch on intersection, and handle the edges. Take the complete file above, point fetchPosts at your own paginated endpoint, and confirm the guard and the stop-at-end path both fire before you ship. Those two lines are what turn a working demo into code you can trust in production.
FAQs
What is the difference between unobserve and disconnect on an IntersectionObserver?
Call unobserve when you want the observer to drop one particular element and carry on with the rest, and call disconnect when you want it to let go of everything it is currently watching. For infinite scroll, that maps to unobserve for pausing the single sentinel while a request is in flight, and disconnect once the data runs out and the observer has no further job to do.
When should I use pagination or a Load more button instead of infinite scroll?
Choose pagination or a Load more button when the footer matters, such as contact links, legal text, or deep-link pagination, because endless auto-loading pushes footer content permanently out of reach and traps keyboard and screen-reader users. Infinite scroll fits open-ended content where an endless stream is the point, like social feeds. When users need a stopping point or must reach the bottom, an explicit control is the better pattern.
Why does threshold 1.0 sometimes fail to trigger infinite scroll?
A threshold of 1.0 requires the observed element to be 100 percent visible before the callback fires, so a sentinel taller than the viewport can never fully enter view and the callback silently never runs. Use threshold 0 combined with a rootMargin buffer instead: the callback then fires as soon as any part of the sentinel crosses the expanded root boundary, which is the more reliable default for infinite scroll.
Do I need to handle multiple entries in the IntersectionObserver callback?
Yes. MDN's constructor reference tells you not to rely on the entries array having any particular length, because a single run of your callback can carry more than one crossing. For a single sentinel, entries[0] often works in practice, but looping over all entries and checking isIntersecting on each is the correct approach and prevents missed or misattributed intersections when more than one target reports at once.
Does infinite scroll break the browser back button?
Yes, infinite scroll can break back-button scroll restoration because the browser tries to return the user to a scroll position that no longer exists in the DOM after dynamically loaded content is discarded on navigation. The user lands at the wrong spot or the top of the list. Mitigations include persisting loaded state, restoring scroll position manually, or offering a Load more button so navigation maps to a stable, reproducible state.