Optimize Infinite Scroll Performance in JavaScript

Advanced18 min interview
Skills tested:
Using IntersectionObserver with a sentinel element to replace scroll event listenersBatching DOM insertions to avoid layout thrashing on each new page of dataUnderstanding DOM virtualization as the solution to unbounded DOM growthDisconnecting the IntersectionObserver when all data is loadedConfiguring rootMargin to trigger loads before the user reaches the bottom

Advertisement

🧩 Scenario

In a real codebase, infinite scroll is used in social feeds, product listings, and news aggregators where the full dataset is too large to load at once. Performance degrades from two separate causes: expensive scroll event processing and growing DOM size. Fixing scroll processing is quick with IntersectionObserver; fixing DOM growth requires virtualization.

Architecture Walkthrough

Problem 1: Scroll Event Overhead

A naive infinite scroll implementation attaches a scroll event listener to window or the container. This event fires continuously while the user scrolls, often 60 or more times per second. Each handler call checks whether the user has scrolled near the bottom using scrollTop + clientHeight >= scrollHeight. This is not free: reading these properties can force a synchronous layout if any pending style or DOM changes have not yet been flushed.

IntersectionObserver is the correct replacement. Instead of running JavaScript on every scroll pixel, you place a sentinel element (an empty div) at the end of the list and observe it. The observer fires only when the sentinel enters or leaves the viewport, meaning the callback runs at most once per page load. The browser handles the visibility calculation natively, off the main thread.

Problem 2: Unbounded DOM Growth

Even with IntersectionObserver, after loading 500 items the DOM has 500 nodes. Every layout calculation (triggered by scrolling, resizing, or reading layout properties) must consider all 500 nodes. Every paint covers the visible portion. Browsers optimize for this, but the overhead is measurable past a few hundred complex nodes.

DOM virtualization solves this by rendering only the visible items plus a small buffer. Items scrolled above the viewport are removed from the DOM. Their height is maintained using a spacer element so the scrollbar and scroll position remain accurate. When the user scrolls back up, items are re-rendered. Libraries like react-virtual, @tanstack/virtual, and react-window implement this pattern.

Batching DOM Insertions

Inserting items one at a time in a for loop causes a separate style recalculation for each insert. Using a DocumentFragment batches all insertions into a single DOM operation. The fragment exists in memory and is appended to the live DOM once, triggering exactly one layout recalculation.


Key Code Explained

const list = document.querySelector('.item-list');
const sentinel = document.querySelector('#load-trigger'); // empty div at the bottom

let page = 1;
let isLoading = false;
let hasMore = true;

const observer = new IntersectionObserver(
  async (entries) => {
    if (!entries[0].isIntersecting || isLoading || !hasMore) return;

    isLoading = true;
    const items = await fetchPage(page++);

    if (items.length === 0) {
      hasMore = false;
      observer.disconnect(); // no more data — stop observing
      return;
    }

    // Batch all DOM insertions into a single operation
    const fragment = document.createDocumentFragment();
    items.forEach((item) => {
      const li = document.createElement('li');
      li.className = 'item';
      li.textContent = item.name;
      fragment.appendChild(li);
    });
    list.appendChild(fragment); // single layout recalculation

    isLoading = false;
  },
  {
    rootMargin: '300px', // trigger 300px before sentinel enters viewport
    threshold: 0,
  },
);

observer.observe(sentinel);

// HTML structure:
// <ul class="item-list"></ul>
// <div id="load-trigger"></div>  ← sentinel stays at the bottom

// For very long lists: virtualization concept
function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount + 2, items.length); // +2 buffer

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflowY: 'scroll', position: 'relative' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
          {visibleItems.map((item) => (
            <div key={item.id} style={{ height: itemHeight }}>
              {item.name}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

The rootMargin: '300px' configuration means the observer fires when the sentinel is within 300 pixels of the viewport edge, not when it actually enters the viewport. This gives the data fetch a head start so new items appear before the user reaches the actual bottom of the list.


Tradeoffs

ApproachScroll overheadDOM size concernImplementation complexity
scroll listener (unthrottled)Very highGrows unboundedlyLow
IntersectionObserverNear zeroGrows unboundedlyLow
IntersectionObserver + virtualNear zeroStays constantHigh

What Interviewers Actually Check

  • Whether you replace scroll listeners with IntersectionObserver and can explain why it is more efficient
  • Whether you know about the DOM growth problem and that IntersectionObserver alone does not fix it
  • Whether you know DocumentFragment for batching DOM insertions
  • Whether you know DOM virtualization as the solution to unbounded list sizes
  • Whether you disconnect the observer when all data is loaded

Follow-Up Questions

  1. How does @tanstack/virtual implement virtualization and how does it handle items of variable height?
  2. If the API page size is 50 items, how do you choose the IntersectionObserver rootMargin to ensure new data is ready before the user reaches the bottom?
  3. How would you add a loading skeleton during the fetch and an error state if the request fails?
  4. What happens to scroll position if you remove items from the top of the list during virtualization, and how do scroll anchoring APIs help?
  5. How would you implement bi-directional infinite scroll that loads both newer and older items as the user scrolls up or down?

Common Candidate Mistakes

  • Not knowing IntersectionObserver exists and proposing a throttled scroll listener as the solution
  • Knowing IntersectionObserver but not recognizing that DOM growth is a separate problem that needs virtualization
  • Inserting list items one at a time in a loop rather than batching with DocumentFragment
  • Not disconnecting the observer after the last page is loaded
  • Not handling the loading state flag (isLoading), which allows the observer to fire multiple times during a single fetch and trigger duplicate requests

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain why IntersectionObserver is more efficient than a scroll event for triggering loads?
  • Can you implement a sentinel-based load trigger with rootMargin for pre-loading?
  • Can you batch DOM insertions using DocumentFragment to avoid multiple reflows?
  • Can you explain what DOM virtualization is and why it is the fix for lists longer than a few hundred items?
  • Can you disconnect an IntersectionObserver when no more data is available?

Summary

Infinite scroll performance degrades from two separate causes. The first is scroll event handler overhead: a handler that fires on every scroll pixel and reads layout properties causes synchronous layout recalculations. IntersectionObserver replaces this with a native viewport-intersection check that fires only when a sentinel element enters the viewport, reducing JavaScript execution to one call per page load.

The second cause is unbounded DOM growth. After loading many pages, the list contains hundreds or thousands of nodes and every layout operation must consider all of them. IntersectionObserver does not help here. DOM virtualization is the fix: only the visible items plus a small buffer are kept in the DOM at any time, maintaining constant rendering cost regardless of total list length.

For typical implementations, use an IntersectionObserver on a sentinel element at the bottom of the list with a rootMargin to trigger early, batch insertions with DocumentFragment, and disconnect the observer when the last page is loaded. For lists that grow past several hundred items, integrate a virtualization library to keep DOM size constant.

Frequently Asked Questions

Why does infinite scroll slow down over time?

Because the DOM grows unboundedly. After hundreds of items, every layout calculation, scroll event, and paint covers thousands of nodes.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement