Browse by section

Web Design 日本語

Building Infinite Scroll with the Intersection Observer API

To build infinite scroll, observe a single “sentinel” element with the Intersection Observer API. There is no longer a reason to compute positions inside a scroll handler.

Write it naively, though, and you will hit the classic bug: the load routine fires repeatedly in rapid succession. This article covers the guards that stop it, how to combine it with an async API, and what to do about the UX problem that infinite scroll creates—users can never reach the footer.

Sponsored

What infinite scroll is, and where it is used

Infinite scroll appends the next batch of content automatically when the user reaches the bottom of the page. There is no “next” button; scrolling is enough.

Typical uses:

  • Social media timelines (X, Instagram)
  • News article listings
  • “Load more” product listings on e-commerce

It is a poor fit, however, for screens built around searching, filtering, and comparing, because you cannot return to “that product on page 3.” That is why pagination persists in e-commerce search results.

Why Intersection Observer instead of the scroll event

It wins on both cost and code size. A scroll handler fires continuously while the finger is moving, and reading getBoundingClientRect() or scrollTop each time forces layout recalculation.

scroll event Intersection Observer
Firing rate Continuous (needs throttling) Only when intersection changes
Detection code Manual coordinate maths One line: entry.isIntersecting
Layout recalculation Easily triggered on every read Optimised internally
Nested scroll containers Separate implementation per parent The root option

Note that the intersection-observer polyfill is no longer needed. Internet Explorer, the only browser that lacked support, is end-of-life, and every current browser has it natively.

Sponsored

The basic setup

Observe an empty element with height, placed after the list—the sentinel. Observing the last list item instead means re-attaching the observer every time you append.

<div id="content">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <div class="item">Item 3</div>
</div>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
#content {
  display: flex;
  flex-direction: column;
}
.item {
  padding: 20px;
  border-bottom: 1px solid #ddd;
}
#sentinel {
  height: 1px;   /* a zero-height element may never intersect */
}
#status {
  text-align: center;
  padding: 10px;
  font-size: 14px;
  color: gray;
}

Do not give the sentinel zero height. A zero-height element has a zero intersection rectangle, and in some environments isIntersecting never becomes true. One pixel is enough.

Stopping repeated firing: the main bug

Without a guard, loading runs continuously while the sentinel is in view. The callback fires several times in the instant before the appended content pushes the sentinel down.

You need two flags: loading in progress and no more data.

const content = document.getElementById('content');
const sentinel = document.getElementById('sentinel');
const status = document.getElementById('status');

let isLoading = false;   // prevents double firing
let hasMore = true;      // no data left
let page = 1;

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      if (isLoading || !hasMore) continue;
      loadMoreContent();
    }
  },
  { rootMargin: '200px' } // start preloading 200px early
);

observer.observe(sentinel);

rootMargin: '200px' starts loading 200px before the sentinel enters view, so users spend less time looking at a spinner.

Sponsored

Fetching from an API and appending

In a real application you call an API rather than generating an array. Handle all three outcomes: success, failure, and end of data.

async function loadMoreContent() {
  isLoading = true;
  status.textContent = 'Loading…';

  try {
    const res = await fetch(`/api/items?page=${page}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const items = await res.json();

    if (items.length === 0) {
      hasMore = false;
      status.textContent = 'All items loaded';
      observer.unobserve(sentinel);
      return;
    }

    const fragment = document.createDocumentFragment();
    for (const item of items) {
      const el = document.createElement('div');
      el.className = 'item';
      el.textContent = item.title;
      fragment.appendChild(el);
    }
    content.appendChild(fragment);

    page += 1;
    status.textContent = '';
  } catch (err) {
    status.textContent = 'Failed to load. Please try again.';
    console.error(err);
  } finally {
    isLoading = false;
  }
}

Three things matter here:

  • Reset the flag in finally. If isLoading stays true after a failure, loading never resumes
  • Batch into a DocumentFragment instead of calling appendChild per item, to reduce reflow
  • unobserve() at the end of the data—there is nothing left to watch for

The footer problem

The biggest downside of infinite scroll is that users can never reach the footer—terms, contact details, sitemap. This is a genuine accessibility problem, not a nitpick.

The options I use:

  • Stop auto-loading after N rounds and switch to a “Load more” button (safest; three to five rounds is a reasonable default)
  • Duplicate footer links into a sidebar or header
  • Use pagination instead (the correct answer for search results and product listings)
const AUTO_LOAD_LIMIT = 5;
let autoLoadCount = 0;

// inside the callback
if (autoLoadCount >= AUTO_LOAD_LIMIT) {
  observer.unobserve(sentinel);
  showLoadMoreButton(); // manual from here on
  return;
}
autoLoadCount += 1;

Announce state with an element carrying role="status" and aria-live="polite", so screen reader users are told about “Loading” and “All items loaded.” That is why it is in the markup above.

Keeping render cost down as the list grows

Infinite scroll never removes DOM nodes, so scrolling itself gets heavy past a few thousand items. CSS content-visibility is the cheap fix.

.item {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px; /* approximate height */
}

The browser skips rendering off-screen elements. Always include contain-intrinsic-size, or the scrollbar will jump.

Beyond that scale—tens of thousands of items—you need virtual scrolling, keeping only visible rows in the DOM. At that point Intersection Observer alone is not enough and you are into library territory.

Common failures and their causes

Symptom Cause and fix
Loading fires many times in a row No isLoading guard; reset it in finally
One error and it never loads again Flag not reset inside catch
Sentinel never triggers Sentinel has zero height or display: none
No trigger inside a nested scroller Set root to the scrolling ancestor
Keeps firing after data runs out unobserve() when you receive an empty array

For other uses of the same API, see optimising lazy loading with Intersection Observer.

Summary

  • Put a 1px sentinel after the list and observe only that
  • Without isLoading and hasMore, loading fires repeatedly. Reset flags in finally
  • rootMargin: '200px' hides the loading state
  • Call unobserve() once the data ends
  • Users cannot reach the footer—switch to a “Load more” button after N rounds
  • Add content-visibility: auto as the list grows
  • The polyfill is no longer necessary

Automatic loading is not the goal; users reaching the information they want is. On screens involving search and comparison, choosing not to use infinite scroll is a legitimate outcome.