Implementing Infinite Scroll with JS Observers

A complete tutorial on implementing infinite scroll with JavaScript Intersection Observer. Covers sentinel element pattern, loading indicators, page tracking, scroll position restoration, error handling with retry, preventing duplicate fetches, virtualized rendering for performance, back-to-top button, and building a complete infinite scroll controller.

JavaScriptintermediate
15 min read

Infinite scroll loads content as the user scrolls, replacing traditional pagination. The Intersection Observer API makes this efficient by detecting when a sentinel element enters the viewport. This guide builds a production-ready implementation with error handling, retry logic, and performance optimization.

For the Intersection Observer API reference, see JS Intersection Observer API: complete tutorial.

Basic Sentinel Pattern

javascriptjavascript
const container = document.getElementById("items");
const sentinel = document.getElementById("scroll-sentinel");
let page = 1;
let isLoading = false;
let hasMore = true;
 
const observer = new IntersectionObserver(
  async (entries) => {
    const entry = entries[0];
    if (!entry.isIntersecting || isLoading || !hasMore) return;
 
    isLoading = true;
    try {
      const items = await fetchPage(page);
 
      if (items.length === 0) {
        hasMore = false;
        sentinel.textContent = "No more items";
        observer.disconnect();
        return;
      }
 
      items.forEach((item) => {
        const el = document.createElement("div");
        el.className = "item";
        el.textContent = item.title;
        container.insertBefore(el, sentinel);
      });
 
      page++;
    } catch (error) {
      console.error("Load failed:", error);
    } finally {
      isLoading = false;
    }
  },
  {
    rootMargin: "0px 0px 300px 0px", // Trigger 300px before viewport
  }
);
 
observer.observe(sentinel);
 
async function fetchPage(pageNum) {
  const response = await fetch(`/api/items?page=${pageNum}&limit=20`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}
htmlhtml
<div id="items">
  <!-- Items are inserted before the sentinel -->
  <div id="scroll-sentinel" class="loading-indicator">Loading...</div>
</div>

Infinite Scroll Controller

javascriptjavascript
class InfiniteScroll {
  constructor(options) {
    this.container = document.querySelector(options.container);
    this.fetchFn = options.fetch;
    this.renderFn = options.render;
    this.pageSize = options.pageSize || 20;
    this.rootMargin = options.rootMargin || "300px";
    this.maxRetries = options.maxRetries || 3;
 
    this.page = 1;
    this.isLoading = false;
    this.hasMore = true;
    this.retryCount = 0;
    this.totalLoaded = 0;
    this.observer = null;
    this.sentinel = null;
    this.statusEl = null;
 
    this.init();
  }
 
  init() {
    // Create sentinel element
    this.sentinel = document.createElement("div");
    this.sentinel.className = "infinite-scroll-sentinel";
    this.container.appendChild(this.sentinel);
 
    // Create status element
    this.statusEl = document.createElement("div");
    this.statusEl.className = "infinite-scroll-status";
    this.statusEl.setAttribute("aria-live", "polite");
    this.container.appendChild(this.statusEl);
 
    // Create observer
    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMore();
        }
      },
      { rootMargin: `0px 0px ${this.rootMargin} 0px` }
    );
 
    this.observer.observe(this.sentinel);
  }
 
  async loadMore() {
    if (this.isLoading || !this.hasMore) return;
 
    this.isLoading = true;
    this.showStatus("loading");
 
    try {
      const items = await this.fetchFn(this.page, this.pageSize);
 
      if (!items || items.length === 0) {
        this.hasMore = false;
        this.showStatus("end");
        this.observer.disconnect();
        return;
      }
 
      // Render items before the sentinel
      const fragment = document.createDocumentFragment();
      items.forEach((item) => {
        const el = this.renderFn(item);
        fragment.appendChild(el);
      });
      this.container.insertBefore(fragment, this.sentinel);
 
      this.totalLoaded += items.length;
      this.page++;
      this.retryCount = 0;
 
      if (items.length < this.pageSize) {
        this.hasMore = false;
        this.showStatus("end");
        this.observer.disconnect();
      } else {
        this.showStatus("idle");
      }
    } catch (error) {
      console.error(`Load failed (attempt ${this.retryCount + 1}):`, error);
      this.retryCount++;
 
      if (this.retryCount >= this.maxRetries) {
        this.showStatus("error");
        this.observer.disconnect();
      } else {
        this.showStatus("retry");
        // Auto-retry after delay
        setTimeout(() => {
          this.isLoading = false;
          this.loadMore();
        }, 1000 * this.retryCount);
        return;
      }
    } finally {
      this.isLoading = false;
    }
  }
 
  showStatus(state) {
    const messages = {
      loading: "Loading more items...",
      end: `All ${this.totalLoaded} items loaded`,
      error: "Failed to load. <button class='retry-btn'>Retry</button>",
      retry: `Retrying... (attempt ${this.retryCount}/${this.maxRetries})`,
      idle: "",
    };
 
    this.statusEl.innerHTML = messages[state] || "";
    this.statusEl.dataset.state = state;
 
    if (state === "error") {
      const retryBtn = this.statusEl.querySelector(".retry-btn");
      retryBtn.addEventListener("click", () => {
        this.retryCount = 0;
        this.hasMore = true;
        this.observer.observe(this.sentinel);
        this.loadMore();
      });
    }
  }
 
  reset() {
    // Clear all items except sentinel and status
    const children = [...this.container.children];
    children.forEach((child) => {
      if (child !== this.sentinel && child !== this.statusEl) {
        child.remove();
      }
    });
 
    this.page = 1;
    this.isLoading = false;
    this.hasMore = true;
    this.retryCount = 0;
    this.totalLoaded = 0;
 
    this.observer.observe(this.sentinel);
    this.loadMore();
  }
 
  destroy() {
    this.observer.disconnect();
    this.sentinel.remove();
    this.statusEl.remove();
  }
}

Usage

javascriptjavascript
const scroller = new InfiniteScroll({
  container: "#article-list",
  pageSize: 20,
  rootMargin: "400px",
  maxRetries: 3,
 
  fetch: async (page, limit) => {
    const res = await fetch(`/api/articles?page=${page}&limit=${limit}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  },
 
  render: (article) => {
    const card = document.createElement("article");
    card.className = "article-card";
    card.innerHTML = `
      <h3>${article.title}</h3>
      <p>${article.excerpt}</p>
      <time>${new Date(article.date).toLocaleDateString()}</time>
    `;
    return card;
  },
});

Scroll Position Restoration

javascriptjavascript
class ScrollRestorer {
  constructor(scrollerInstance) {
    this.scroller = scrollerInstance;
    this.storageKey = `scroll:${window.location.pathname}`;
  }
 
  save() {
    const state = {
      scrollY: window.scrollY,
      page: this.scroller.page,
      totalLoaded: this.scroller.totalLoaded,
    };
    sessionStorage.setItem(this.storageKey, JSON.stringify(state));
  }
 
  async restore() {
    const raw = sessionStorage.getItem(this.storageKey);
    if (!raw) return false;
 
    try {
      const state = JSON.parse(raw);
 
      // Load all pages up to saved position
      for (let p = 1; p < state.page; p++) {
        await this.scroller.loadMore();
      }
 
      // Restore scroll position
      requestAnimationFrame(() => {
        window.scrollTo(0, state.scrollY);
      });
 
      return true;
    } catch {
      return false;
    }
  }
 
  init() {
    // Save position before leaving
    window.addEventListener("beforeunload", () => this.save());
 
    // Save on link clicks
    document.addEventListener("click", (event) => {
      if (event.target.closest("a[href]")) {
        this.save();
      }
    });
  }
}
 
const restorer = new ScrollRestorer(scroller);
restorer.init();
restorer.restore();

See JS sessionStorage API guide for more on session-scoped storage.

Back-to-Top Button

javascriptjavascript
function createBackToTop(options = {}) {
  const threshold = options.threshold || 500;
 
  const button = document.createElement("button");
  button.className = "back-to-top";
  button.setAttribute("aria-label", "Back to top");
  button.innerHTML = "&#8593;";
  button.style.cssText = `
    position: fixed; bottom: 24px; right: 24px;
    width: 44px; height: 44px; border-radius: 50%;
    border: none; background: #3b82f6; color: white;
    font-size: 20px; cursor: pointer; opacity: 0;
    transition: opacity 0.3s; z-index: 1000;
  `;
 
  document.body.appendChild(button);
 
  const topSentinel = document.createElement("div");
  topSentinel.style.cssText = `position: absolute; top: ${threshold}px; height: 1px;`;
  document.body.appendChild(topSentinel);
 
  const observer = new IntersectionObserver((entries) => {
    button.style.opacity = entries[0].isIntersecting ? "0" : "1";
    button.style.pointerEvents = entries[0].isIntersecting ? "none" : "auto";
  });
 
  observer.observe(topSentinel);
 
  button.addEventListener("click", () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  });
}
 
createBackToTop({ threshold: 600 });

Performance Considerations

TechniqueBenefitWhen to Use
rootMargin: "300px"Pre-fetches before user reaches bottomAlways
DocumentFragmentBatch DOM insertionsMany items per page
Debounce fetchesPrevent rapid duplicate requestsFast scrolling
Skeleton placeholdersPerceived performanceLoading states
requestIdleCallbackNon-urgent renderingLow-priority items
Virtual scrollingHandle 10K+ itemsVery large lists
javascriptjavascript
// Batch rendering with requestAnimationFrame
function batchRender(items, renderFn, container, batchSize = 50) {
  let index = 0;
 
  function renderBatch() {
    const fragment = document.createDocumentFragment();
    const end = Math.min(index + batchSize, items.length);
 
    for (let i = index; i < end; i++) {
      fragment.appendChild(renderFn(items[i]));
    }
 
    container.appendChild(fragment);
    index = end;
 
    if (index < items.length) {
      requestAnimationFrame(renderBatch);
    }
  }
 
  requestAnimationFrame(renderBatch);
}

Accessibility

javascriptjavascript
// Announce new content to screen readers
function announceNewItems(count) {
  const announcer = document.getElementById("scroll-announcer") ||
    (() => {
      const el = document.createElement("div");
      el.id = "scroll-announcer";
      el.setAttribute("aria-live", "polite");
      el.setAttribute("aria-atomic", "true");
      el.className = "sr-only";
      document.body.appendChild(el);
      return el;
    })();
 
  announcer.textContent = "";
  requestAnimationFrame(() => {
    announcer.textContent = `${count} new items loaded`;
  });
}
 
// Add keyboard shortcut to load more
document.addEventListener("keydown", (event) => {
  if (event.key === "End" && !scroller.isLoading && scroller.hasMore) {
    scroller.loadMore();
  }
});
Rune AI

Rune AI

Key Insights

  • Sentinel element pattern: Place an invisible element at the bottom of the list; when it enters the viewport, trigger the next fetch
  • rootMargin for pre-fetching: Set rootMargin: "300px" to start loading before the user reaches the bottom, creating a seamless experience
  • Loading and hasMore flags: Prevent duplicate fetches with an isLoading flag and stop observing when hasMore is false
  • Error handling with retry: Automatically retry failed fetches with exponential backoff; show a manual retry button after max attempts
  • DocumentFragment for batch DOM insertion: Append all new items to a fragment first, then insert once to minimize reflows and repaints
RunePowered by Rune AI

Frequently Asked Questions

Why use Intersection Observer instead of scroll events for infinite scroll?

Scroll events fire on every pixel of scroll and run on the main thread, causing jank. Intersection Observer runs off the main thread and only fires when the sentinel element crosses the configured threshold. It is significantly more performant, especially on mobile devices.

How do I prevent duplicate fetches during fast scrolling?

The `isLoading` flag in the controller prevents concurrent fetches. The observer callback checks this flag before calling `loadMore()`. Additionally, the sentinel element stays below lazy-loaded content, so it only becomes visible again after the previous batch renders.

Should I use infinite scroll or traditional pagination?

Use infinite scroll for browse/discovery feeds (social media, news, image galleries). Use traditional pagination for search results, data tables, and content where users need to bookmark or share specific pages. Infinite scroll is poor for SEO because search engines cannot easily crawl paginated content. See [creating an SPA router with the JS History API](/tutorials/programming-languages/javascript/creating-an-spa-router-with-the-js-history-api) for URL-based pagination.

How do I handle the case where all items fit on one screen?

If the initial page of items does not fill the viewport, the sentinel is immediately visible and the observer fires again. The controller handles this by checking `hasMore` and `isLoading` flags. It will keep loading pages until the content fills the screen or there are no more items.

Can I combine infinite scroll with URL-based pagination?

Yes. Update the URL query parameter (e.g., `?page=3`) with `history.replaceState` as pages load. On page load, read the page parameter and pre-load all pages up to that number. This enables bookmarking and sharing while keeping the infinite scroll UX.

Conclusion

Infinite scroll with Intersection Observer replaces expensive scroll listeners with a performant sentinel-based approach. Use the controller class for error handling, retry logic, and status feedback. Add scroll position restoration with sessionStorage, back-to-top buttons, and screen reader announcements for accessibility. For the Observer API reference, see JS Intersection Observer API: complete tutorial. For DOM change monitoring, see JavaScript Mutation Observer: complete tutorial.