Implementing Infinite Scroll with JS Observers
Build infinite scroll the modern way using IntersectionObserver instead of scroll event listeners. Load content in pages as the reader nears the bottom of the list.
Infinite scroll JavaScript patterns fetch and append more items automatically as the reader scrolls near the bottom of a list, instead of the reader clicking a next page button. This article builds a working infinite scroll feed using the IntersectionObserver API, which is the modern replacement for tracking scroll position manually.
You will build a product list that starts with one page of items and automatically loads the next page when the reader scrolls close to the end.
const list = document.querySelector("#product-list");
const sentinel = document.querySelector("#sentinel");
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
console.log("Sentinel visible, load more items");
}
});
observer.observe(sentinel);When the empty sentinel element scrolls into the viewport, the callback logs a message. The rest of this article replaces that log statement with real data fetching, pagination state, and cleanup.
Why Not Use the scroll Event
A scroll event fires dozens of times per second while the user scrolls. Checking an element's position inside that handler forces the browser to recompute layout on every single call, which can make the page feel slow on long lists or low-powered devices.
IntersectionObserver avoids both problems. The browser calculates intersections asynchronously in its own step and only invokes the callback when the observed element's visibility actually changes. The same API also powers lazy loading images and components, since both patterns depend on knowing when an element enters the viewport.
The left path represents the older scroll-listener approach: constant events and repeated layout work. The right path shows the observer approach used in this article, where the browser does the tracking and only notifies your code when something meaningful changes.
Step 1: The HTML Structure
Keep the markup simple: a container for items and an empty sentinel element right after it.
<ul id="product-list"></ul>
<div id="sentinel"></div>
<p id="status"></p>The sentinel has no content and no visible size requirement of its own. Its only job is to sit at the bottom of the list so the observer can detect when the reader scrolls near it.
Step 2: Fetching a Page of Data
Each call to the load function below fetches one page of items and hands them off to a render function. A page counter tracks which page to request next, and a loading flag prevents a second fetch from overlapping the first:
let page = 1;
let isLoading = false;
let hasMore = true;
async function loadNextPage() {
if (isLoading || !hasMore) return;
isLoading = true;
const response = await fetch(`/api/products?page=${page}&limit=10`);
const data = await response.json();
renderItems(data.items);
hasMore = data.items.length > 0;
page += 1;
isLoading = false;
}The isLoading flag stops a second fetch from starting while the first one is still in flight, which would otherwise happen if the sentinel triggers the callback again before the previous page finishes rendering. hasMore becomes false once the server returns an empty page, so the code knows there is nothing left to load.
Rendering is kept in its own small function, since it only needs to append one list item per product returned by the fetch call above:
function renderItems(items) {
const list = document.querySelector("#product-list");
for (const item of items) {
const li = document.createElement("li");
li.textContent = item.name;
list.appendChild(li);
}
}This function does not know anything about pages or loading state. It only takes an array of items and appends one list item per entry, which keeps the fetching logic above focused on pagination instead of DOM details.
Step 3: Connecting the Observer
Now wire the sentinel to the load function defined above, so scrolling near the bottom of the list actually triggers a fetch instead of just logging a message.
const sentinel = document.querySelector("#sentinel");
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadNextPage();
}
},
{ rootMargin: "200px" }
);
observer.observe(sentinel);
loadNextPage();rootMargin: "200px" grows the observer's detection area by 200 pixels before the actual viewport edge. This means the next page starts loading while the sentinel is still 200 pixels below the visible area, so new items are usually ready by the time the reader actually reaches the bottom.
Step 4: Stopping When There Is No More Data
Once there are no more pages left, calling the observer's disconnect method stops the browser from watching an element that will never need to trigger a load again. The final version of loadNextPage adds that check right after the loading flag resets:
async function loadNextPage() {
if (isLoading || !hasMore) return;
isLoading = true;
const response = await fetch(`/api/products?page=${page}&limit=10`);
const data = await response.json();
renderItems(data.items);
hasMore = data.items.length > 0;
page += 1;
isLoading = false;
if (!hasMore) {
observer.disconnect();
document.querySelector("#status").textContent = "No more products.";
}
}Disconnecting removes every observed target and shuts the observer down completely. Leaving an inactive observer running does not cause bugs, but disconnecting it once it has no purpose is a small cleanup habit worth keeping, especially in single-page apps where the list component might unmount later.
Common Mistakes
| Mistake | Result | Fix |
|---|---|---|
No isLoading flag | The same page can be requested multiple times if the sentinel triggers again before the fetch resolves | Guard loadNextPage() with an isLoading check |
| Observing individual list items instead of a sentinel | The callback fires constantly as items scroll past, not just at the bottom | Use one small sentinel element placed after the last item |
No rootMargin | New items only start loading once the sentinel is already visible, causing a visible pause at the bottom | Add rootMargin to start loading slightly before the reader reaches the end |
| Forgetting to disconnect when data runs out | The observer keeps firing the callback for an element that will never load new data again | Call observer.disconnect() once hasMore is false |
When to Use Infinite Scroll
Infinite scroll works well for content feeds where the reader browses casually, like social feeds, image galleries, or product listings. It works poorly for content the reader needs to reference by position, such as search results someone might want to bookmark or share, since there is no stable URL for a specific page number.
For those cases, numbered pagination or a load more button that updates the URL is usually the better choice. If you are building the click-based version instead of a scroll trigger, see debouncing in JavaScript to avoid firing multiple loads from a rapidly clicked button.
Rune AI
Key Insights
- IntersectionObserver reports visibility changes asynchronously instead of forcing layout recalculations on every scroll event.
- A sentinel element placed after the last item triggers the next page load when it enters the viewport.
- A loading flag prevents the same page from being fetched twice while a request is in flight.
- rootMargin lets you start loading before the sentinel is fully visible, so new items appear before the reader reaches the true bottom.
- Call observer.disconnect() once there is no more data, so the browser stops watching an element that will never trigger again.
Frequently Asked Questions
Why use IntersectionObserver instead of a scroll event listener?
Does infinite scroll work without JavaScript?
Conclusion
Infinite scroll built on IntersectionObserver avoids the performance cost of scroll event listeners and manual position math. A single sentinel element, a loading flag to prevent duplicate fetches, and a disconnect call when the list ends are enough to build a smooth, reusable pattern for any feed or list.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.