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.

9 min read

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.

javascriptjavascript
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.

Scroll listener vs IntersectionObserver

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.

htmlhtml
<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:

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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:

javascriptjavascript
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

MistakeResultFix
No isLoading flagThe same page can be requested multiple times if the sentinel triggers again before the fetch resolvesGuard loadNextPage() with an isLoading check
Observing individual list items instead of a sentinelThe callback fires constantly as items scroll past, not just at the bottomUse one small sentinel element placed after the last item
No rootMarginNew items only start loading once the sentinel is already visible, causing a visible pause at the bottomAdd rootMargin to start loading slightly before the reader reaches the end
Forgetting to disconnect when data runs outThe observer keeps firing the callback for an element that will never load new data againCall 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why use IntersectionObserver instead of a scroll event listener?

A scroll event fires many times per second while the user scrolls, and computing element positions on every event forces the browser to recalculate layout repeatedly. IntersectionObserver runs asynchronously off the main thread and only reports changes, which is far cheaper and smoother.

Does infinite scroll work without JavaScript?

No. Infinite scroll depends entirely on JavaScript to detect scroll position and fetch new content. Always keep a working pagination fallback, such as numbered page links, for users with JavaScript disabled or for search engine crawlers.

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.