How to Build High-Performance Infinite Scroll Without Losing Your Mind

Mechatronics, Software Engineering, Woodworking, and "Making" in General

How to Build High-Performance Infinite Scroll Without Losing Your Mind

By David Inwald

Infinite scroll is one of those UI patterns that seems deceptively simple. You fetch some data, append it to a list, and boom — you’re TikTok. But any engineer who’s built a real infinite scroll for a production, data-heavy application knows the truth:

Infinite scroll is where UI performance, network constraints, state management, and UX collide.

It’s the kind of feature that “just works” only when dozens of small decisions are made precisely right. And because infinite scroll is often the backbone of modern feed-based applications — analytics dashboards, message streams, logs viewers, search results, product catalogs — it’s surprisingly easy to implement poorly and surprisingly hard to do well.

This post is a deep dive into building a high-performance, fault-tolerant, user-friendly infinite scroll system in React. No magic; no over-engineering; just practical patterns and the mental models that make infinite scroll predictable instead of chaotic.


1. Infinite Scroll Is Not One Problem — It’s Four

Before writing any code, recognize that you’re solving four intertwined problems:

1. Rendering

How do you display thousands of items without destroying the DOM, layout, or browser memory?

2. Data Fetching

How do you request pages predictably, avoid duplicate fetches, and maintain ordering when the user scrolls fast?

3. State Management

How do you maintain consistent internal state when pages load out of order, fail, or get cancelled?

4. UX & Interaction Psychology

How do you avoid jank, content jumps, double-loading indicators, or the “did this break?” feeling?

If you treat infinite scroll as a single fetch-on-scroll event, you will hit one of those failure modes within a day.


2. Why naïve infinite scroll breaks (and keeps breaking)

A junior engineer’s implementation usually looks like:

useEffect(() => {
  const onScroll = () => {
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
      fetchMore();
    }
  };
  window.addEventListener("scroll", onScroll);
  return () => window.removeEventListener("scroll", onScroll);
}, []);

This works for a weekend project.

In real apps, here’s what goes wrong:

Problem 1: Scroll event spam

Scroll fires constantly. You get bursty calls to fetchMore(), often before the previous fetch resolves.

Problem 2: Duplicate page requests

Network latency + user scroll velocity = overlapping requests. If you don’t dedupe, you get races and ghost items.

Problem 3: Janky layout shifts

If content loads in unexpectedly or the scroll position is not preserved, the user’s viewport jumps around.

Problem 4: Memory ballooning

Rendering thousands of DOM nodes tanks performance. Even React’s reconciliation slows with huge trees.

Problem 5: Incorrect load boundaries

Users scroll fast → fetch boundary triggers late → blank dead zones appear.

Problem 6: Infinite scroll + filters/sorting

Resets are expensive because you must reliably cancel ongoing fetches and invalidate pages.

Each of these requires a deliberate solution.


3. The Modern Mental Model: Windowing + IntersectionObserver + Predictable Fetching

High-performance infinite scroll is built from three key building blocks.

3.1 Windowing (a.k.a. virtualization)

Rule #1: Never render more than what the user can see plus a buffer.

Even React’s best optimizations can’t save you from 10,000 DOM nodes. Libraries like:

  • react-window (lighter)
  • react-virtualized (older but powerful)
  • TanStack Virtual (excellent, modern)

…exist for this exact reason.

Windowing ensures that rendering cost is O(visible items) rather than O(total items).

If your infinite scroll does not use virtualization, it will fail at scale. Full stop.


3.2 IntersectionObserver for triggering loads

No more scroll-event math.
Attach a “sentinel” div at the bottom of your virtualized list.

const ref = useRef();

useEffect(() => {
  if (!ref.current) return;
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) loadMore();
  });
  observer.observe(ref.current);
  return () => observer.disconnect();
}, [loadMore]);

This decouples load timing from scroll velocity — the browser tells you when the user is visually near the boundary.


3.3 Predictable fetch scheduling

A robust infinite scroll should:

  • Track the current page index
  • Block new requests until the current one resolves
  • Cancel or ignore stale fetches
  • Handle gaps when pages fail
  • Maintain a consistent chronological order
  • Allow late pages to still be inserted correctly

This is the part most implementations skip and where 80% of bugs live.


4. Architecture of a Reliable Infinite Scroll System

Here’s an opinionated architecture that scales:


4.1 Fetch layer: A “Page Fetcher” that guarantees idempotence

Design a function:

async function fetchPage(page: number): Promise<PageResult> {
  // Must:
  // - throw on error
  // - return consistent shape
  // - be deterministic for given page inputs
}

Key properties:

  • Idempotent — fetching page 5 always gives the same result
  • Stateless — page fetcher knows nothing about scroll state
  • Strict error semantics

This keeps your UI clean.


4.2 State layer: A reducer that models fetch lifecycle

Use a reducer-based state machine:

  • idle
  • loading
  • success
  • error
  • exhausted

This lets you express all edge cases clearly.

Example snippet:

type State = {
  pages: Record<number, Item[]>;
  loadingPage: number | null;
  exhausted: boolean;
  error: null | string;
};

This pays dividends when debugging page holes or duplicate requests.


4.3 UI layer: Virtualized list + sentinel + load indicators

The UI layer handles only:

  • Rendering items
  • Rendering placeholders
  • Exposing the sentinel ref
  • Showing errors or retry buttons
  • Scroll position preservation

By keeping state + fetch + UI separate, infinite scroll becomes maintainable.


5. Performance Principles You Must Respect

5.1 Batch DOM updates

Render items in chunks rather than one-by-one.
Virtualization handles most of this for you.


5.2 Avoid unbounded arrays

Never do:

setItems([...items, ...newItems])

for a 100,000-item feed.

Virtualized lists should store data in pages, not one huge array. This speeds reconciliation and makes invalidation easier.


5.3 Cache previously loaded pages

When a user scrolls up again, they shouldn’t trigger network calls.

Consider using:

  • React Query
  • SWR
  • A custom LRU
  • Static page maps

Caching is critical for perceived performance.


5.4 Preserve scroll position on reload

The moment your app “jumps” is the moment your UX feels broken.

Scroll anchoring + stable height assumptions + correct virtualization solves this.


6. Handling Advanced Real-World Scenarios

These are the issues that separate senior-level infinite scroll from the toy versions.


6.1 Filters and sorting

When filters change:

  • Cancel all in-flight requests
  • Reset pages to {}
  • Reset page cursor to 0
  • Fire an initial load
  • Do not reuse stale pages from old filters

Filter transitions are the #1 source of ghost results and bad ordering.


6.2 Backwards pagination (chat-style)

Make sure you:

  • Anchor scroll position when prepending messages
  • Avoid resetting scroll to the top
  • Load the next page above the current viewport
  • Measure the pre-load list height
  • After prepend, scroll back to preserve exact viewport offset

Otherwise you get “scroll jumps” every time history loads.


6.3 Dead zones when user scrolls too fast

IntersectionObserver events may fire late.

Mitigation strategies:

  • Use a larger prefetch threshold (virtualization library option)
  • Preload multiple pages when user velocity is high
  • Use predictive fetch (look at scroll direction + momentum)

6.4 Error boundaries per page

A single failed page should not break the entire feed.
You should show:

  • a retry button inline
  • a partial failure indicator
  • maintain all other pages

This is important for resiliency.


7. What “Fast” Feels Like (UX Psychology)

Users perceive infinite scroll performance based on:

1. Consistency

Never skip pages; never re-order items; never flash placeholders.

2. Predictability

Indicators appear when expected.
Scrolling downward always yields new content.

3. Smoothness

No layout shift, no jumps, no jank.

4. Flow continuity

Content keeps arriving “just before” the user needs it.

5. Error transparency

If something fails, the interface communicates clearly and locally.

Infinite scroll that feels good is invisible.
Bad infinite scroll demands attention.


8. A Complete Example Implementation (React + TS)

Here is a simplified, production-grade pattern that reflects the principles above.

function useInfiniteFeed(fetchPage: (p: number) => Promise<Item[]>) {
  const [pages, setPages] = useState<Record<number, Item[]>>({});
  const [page, setPage] = useState(0);
  const [loading, setLoading] = useState(false);
  const [exhausted, setExhausted] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || exhausted) return;

    setLoading(true);
    setError(null);

    try {
      const items = await fetchPage(page);
      if (items.length === 0) {
        setExhausted(true);
      } else {
        setPages(p => ({ ...p, [page]: items }));
        setPage(p => p + 1);
      }
    } catch (err) {
      setError(String(err));
    } finally {
      setLoading(false);
    }
  }, [loading, page, fetchPage, exhausted]);

  return { pages, loadMore, loading, exhausted, error };
}

UI:

function Feed() {
  const { pages, loadMore, loading, exhausted, error } = useInfiniteFeed(fetchPage);

  const items = Object.values(pages).flat();

  const sentinelRef = useRef(null);

  useEffect(() => {
    const el = sentinelRef.current;
    if (!el) return;

    const obs = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) loadMore();
    });

    obs.observe(el);
    return () => obs.disconnect();
  }, [loadMore]);

  return (
    <div>
      {items.map(item => <Item key={item.id} {...item} />)}

      {!exhausted && <div ref={sentinelRef} style={{ height: 1 }} />}

      {loading && <div>Loading...</div>}
      {error && <button onClick={loadMore}>Retry</button>}
    </div>
  );
}

This is intentionally simplified — in production, you’d integrate virtualization and stronger reducer semantics — but the pattern is solid.


9. Conclusion: Infinite Scroll Is a System, Not a Feature

The reason infinite scroll feels tricky is because it isn’t a single technical concern — it’s the convergence of:

  • scroll mechanics
  • virtualization
  • concurrency
  • error handling
  • user psychology
  • data modeling
  • network timing
  • UX polish
  • state-machine correctness

When infinite scroll is built thoughtfully, users don’t even notice it.
When it’s built haphazardly, every user notices it.

A good infinite scroll doesn’t just fetch new data;
it protects continuity of experience.
It makes the user feel like the content is infinite because the interface is infinite — not because the data is.

And when you get it right, it’s one of the most satisfying pieces of UI engineering there is.