ReactIntersectionObserverPaginationPerformanceAPI

Build Infinite Scroll

Learn how to implement infinite scrolling from scratch using IntersectionObserver and a paginated API. This is a common frontend interview question that tests your understanding of browser APIs, async data fetching, and performance optimization.

30 min read8 sections
01

Problem Statement

Build a feed that automatically loads more content as the user scrolls down. The data comes from a paginated API that returns items in batches with ahasMore flag.

  • Fetch the first page of posts on mount
  • Automatically load the next page when the user scrolls near the bottom
  • Show a loading spinner while fetching
  • Stop fetching when there are no more pages
  • Display a count of loaded vs total items
  • Handle errors and edge cases gracefully

Why this question?

Infinite scroll is everywhere — Twitter, Instagram, Reddit, news feeds. It tests your ability to work with browser APIs (IntersectionObserver), manage async state, handle pagination, and think about performance. It's a step up from basic CRUD.

02

Pagination vs Infinite Scroll

Before building, understand when to use each approach:

PaginationInfinite Scroll
UXUser clicks "Next" / page numbersContent loads automatically on scroll
Best forSearch results, data tables, e-commerceSocial feeds, image galleries, news
SEOBetter — each page has a unique URLHarder — content is dynamically loaded
MemoryFixed — only one page in DOMGrows — all loaded items stay in DOM
📄

Offset-based Pagination

Uses page + limit params. Simple but can skip or duplicate items if data changes between requests.

🔖

Cursor-based Pagination

Uses a cursor (last item ID or timestamp). More reliable for real-time feeds where items are added/removed.

03

Backend API Design

The API returns paginated data with metadata the client needs to know whether to keep fetching.

Endpoint

MethodEndpointDescription
GET/api/v1/posts?page=1&limit=10Fetch a page of posts

Response Shape

API Responsetypescript
{
  "data": [
    {
      "id": 1,
      "title": "Post #1",
      "body": "This is the body of post 1...",
      "author": "user_1",
      "createdAt": "2024-01-15T10:00:00.000Z"
    }
    // ... more posts
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 100,
    "totalPages": 10,
    "hasMore": true    // ← Key field for infinite scroll
  }
}

API Route Handler

app/api/v1/posts/route.tstypescript
// Generate 100 fake posts
const posts = Array.from({ length: 100 }, (_, i) => ({
  id: i + 1,
  title: `Post #${i + 1}`,
  body: `This is the body of post ${i + 1}...`,
  author: `user_${(i % 12) + 1}`,
  createdAt: new Date(Date.now() - i * 3600000).toISOString(),
}));

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const page = Math.max(1, parseInt(
    searchParams.get("page") || "1", 10
  ));
  const limit = Math.min(50, Math.max(1, parseInt(
    searchParams.get("limit") || "10", 10
  )));

  // Simulate network latency
  await new Promise((r) => setTimeout(r, 500));

  const start = (page - 1) * limit;
  const end = start + limit;
  const data = posts.slice(start, end);

  return Response.json({
    data,
    meta: {
      page,
      limit,
      total: posts.length,
      totalPages: Math.ceil(posts.length / limit),
      hasMore: end < posts.length,
    },
  });
}

Interview tip

Always validate and clamp query params on the server. Notice how we useMath.max andMath.min to prevent negative pages or absurdly large limits. Small detail, big signal.

04

IntersectionObserver Explained

IntersectionObserver is a browser API that tells you when an element enters or exits the viewport. It's the modern, performant way to implement infinite scroll — no scroll event listeners needed.

How it works

1

Create an observer

Instantiate a new IntersectionObserver with a callback function and optional config (root, rootMargin, threshold).

2

Observe a sentinel element

Place an invisible div at the bottom of your list. Tell the observer to watch it.

3

Callback fires

When the sentinel scrolls into view (intersects the viewport), the callback fires with an array of IntersectionObserverEntry objects.

4

Load more data

Check entry.isIntersecting — if true, fetch the next page. The sentinel stays at the bottom, so it triggers again when new content pushes it down.

IntersectionObserver basicstypescript
// Create observer
const observer = new IntersectionObserver(
  (entries) => {
    // entries[0] is our sentinel element
    if (entries[0].isIntersecting) {
      loadNextPage();
    }
  },
  {
    root: null,          // viewport
    rootMargin: "100px", // trigger 100px before visible
    threshold: 0,        // any intersection counts
  }
);

// Start observing
const sentinel = document.getElementById("scroll-sentinel");
observer.observe(sentinel);

// Cleanup (important!)
observer.disconnect();

Why not scroll events?

Scroll event listeners fire on every pixel of scroll — potentially 60+ times per second. You'd need to debounce or throttle them, calculate scroll positions manually, and they run on the main thread. IntersectionObserver is async, efficient, and purpose-built for this exact use case.

05

Frontend Implementation

The implementation has three parts: state management, the IntersectionObserver hook, and the UI. Let's build each one.

Step 1: Types & State

Types and initial statetypescript
interface Post {
  id: number;
  title: string;
  body: string;
  author: string;
  createdAt: string;
}

interface Meta {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasMore: boolean;
}

// Component state
const [posts, setPosts] = useState<Post[]>([]);
const [meta, setMeta] = useState<Meta | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);

Step 2: Fetch Function

Data fetchingtypescript
const fetchPosts = async (pageNum: number) => {
  setLoading(true);
  try {
    const res = await makeHttpCall(
      getApiURL(`posts?page=${pageNum}&limit=10`)
    );
    const json = await res.json();

    // Append new posts to existing list
    setPosts((prev) => [...prev, ...json.data]);
    setMeta(json.meta);
  } catch (err) {
    console.error("Failed to fetch posts:", err);
  } finally {
    setLoading(false);
  }
};

// Load first page on mount
useEffect(() => {
  fetchPosts(1);
}, []);

Step 3: IntersectionObserver Hook

This is the core of infinite scroll. We observe a sentinel element at the bottom of the list and trigger loading when it becomes visible.

IntersectionObserver setuptypescript
const sentinelRef = useRef<HTMLDivElement>(null);

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

  const observer = new IntersectionObserver(
    (entries) => {
      // When sentinel is visible and we can load more
      if (entries[0].isIntersecting && !loading && meta?.hasMore) {
        const nextPage = page + 1;
        setPage(nextPage);
        fetchPosts(nextPage);
      }
    },
    { rootMargin: "200px" } // Start loading 200px before visible
  );

  observer.observe(sentinel);

  // Cleanup: disconnect when deps change or unmount
  return () => observer.disconnect();
}, [loading, meta?.hasMore, page]);

Step 4: Complete Component

app/(solution-apps)/questions/infinite-scroll/page.tsxtypescript
"use client";

import { useState, useEffect, useRef } from "react";
import { getApiURL, makeHttpCall } from "@/utils/helper";

interface Post {
  id: number;
  title: string;
  body: string;
  author: string;
  createdAt: string;
}

interface Meta {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasMore: boolean;
}

export default function InfiniteScrollPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [meta, setMeta] = useState<Meta | null>(null);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const sentinelRef = useRef<HTMLDivElement>(null);

  const fetchPosts = async (pageNum: number) => {
    setLoading(true);
    try {
      const res = await makeHttpCall(
        getApiURL(`posts?page=${pageNum}&limit=10`)
      );
      const json = await res.json();
      setPosts((prev) => [...prev, ...json.data]);
      setMeta(json.meta);
    } catch (err) {
      console.error("Failed to fetch posts:", err);
    } finally {
      setLoading(false);
    }
  };

  // Load first page
  useEffect(() => {
    fetchPosts(1);
  }, []);

  // Infinite scroll observer
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (
          entries[0].isIntersecting &&
          !loading &&
          meta?.hasMore
        ) {
          const nextPage = page + 1;
          setPage(nextPage);
          fetchPosts(nextPage);
        }
      },
      { rootMargin: "200px" }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loading, meta?.hasMore, page]);

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}

      {loading && <Spinner />}

      {/* Sentinel — triggers next page load */}
      <div ref={sentinelRef} />

      {meta && !meta.hasMore && (
        <p>You've reached the end.</p>
      )}
    </div>
  );
}

Key detail: rootMargin

Setting rootMargin: "200px" means the observer triggers 200px before the sentinel is actually visible. This gives the API call a head start so content appears seamlessly. Without it, users see a flash of the loading spinner.

06

Data Flow

Here's how data flows through the application from initial load through subsequent page fetches:

1

Component mounts

useEffect fires fetchPosts(1). Loading state is set to true. The API returns the first 10 posts with meta.hasMore = true.

2

Posts render, sentinel is in DOM

The 10 posts render. The sentinel div sits below them. The IntersectionObserver starts watching it.

3

User scrolls down

As the user scrolls, the sentinel approaches the viewport. With rootMargin: '200px', the observer fires 200px early.

4

Observer callback fires

The callback checks: isIntersecting? Yes. loading? No. hasMore? Yes. It increments the page and calls fetchPosts(2).

5

New posts append

The API returns posts 11-20. setPosts appends them to the existing array. The sentinel is pushed further down. The cycle repeats.

6

End of data

When page 10 loads, meta.hasMore is false. The observer callback checks hasMore and does nothing. An 'end of list' message appears.

07

Performance Considerations

Infinite scroll can become a performance problem if not handled carefully. Here's what's in place and what you could add:

✓ Done

IntersectionObserver over scroll events

Runs asynchronously off the main thread. No debouncing needed. Much more efficient than listening to scroll events.

✓ Done

Loading guard

The observer callback checks `loading` before fetching. This prevents duplicate requests when the sentinel is visible during a fetch.

✓ Done

Append-only state updates

Using setPosts(prev => [...prev, ...newPosts]) avoids replacing the entire array. React can diff efficiently.

✓ Done

Server-side latency simulation

The API has a 500ms delay to simulate real network conditions. Your UI should handle this gracefully with a spinner.

→ Could add

Virtual scrolling

After loading hundreds of items, the DOM gets heavy. Libraries like react-window or react-virtuoso only render visible items, keeping the DOM small.

→ Could add

Image lazy loading

If posts contain images, add loading='lazy' to img tags or use another IntersectionObserver to load images only when they're near the viewport.

→ Could add

Request deduplication

Use AbortController to cancel in-flight requests when a new one starts. Prevents race conditions with rapid scrolling.

→ Could add

Cursor-based pagination

Replace page/offset with a cursor (last item ID). More reliable when items are added or removed between requests.

Example: AbortController for request cancellationtypescript
const abortRef = useRef<AbortController>();

const fetchPosts = async (pageNum: number) => {
  // Cancel previous in-flight request
  abortRef.current?.abort();
  abortRef.current = new AbortController();

  setLoading(true);
  try {
    const res = await fetch(
      `/api/v1/posts?page=${pageNum}&limit=10`,
      { signal: abortRef.current.signal }
    );
    const json = await res.json();
    setPosts((prev) => [...prev, ...json.data]);
    setMeta(json.meta);
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      return; // Request was cancelled, ignore
    }
    console.error("Failed to fetch:", err);
  } finally {
    setLoading(false);
  }
};

// Cleanup on unmount
useEffect(() => {
  return () => abortRef.current?.abort();
}, []);
08

Common Interview Follow-up Questions

After implementing infinite scroll, interviewers dig into edge cases and architectural decisions. Here are the most common follow-ups:

Q:How do you prevent duplicate API calls when the user scrolls fast?

A: The loading guard in the observer callback prevents this — if loading is true, the callback returns early. You can also use AbortController to cancel in-flight requests. For extra safety, track fetched page numbers in a Set to avoid re-fetching.

Q:What happens if the user scrolls back up and then down again?

A: The sentinel is always at the bottom of the loaded content. Scrolling up moves it out of view. Scrolling back down triggers the observer again, but the loading guard and hasMore check prevent unnecessary fetches. Already-loaded posts stay in state.

Q:How would you handle errors (network failure, 500 response)?

A: Show an error message with a 'Retry' button instead of the spinner. Don't increment the page number on failure so the retry fetches the same page. Add exponential backoff for automatic retries. Always set loading to false in a finally block.

Q:What if the list has 10,000 items? Won't the DOM get slow?

A: Yes — this is where virtual scrolling comes in. Libraries like react-window or react-virtuoso only render items visible in the viewport (plus a small buffer). The DOM stays small regardless of total items. You'd replace the simple map with a virtualized list.

Q:How would you add 'scroll to top' functionality?

A: Add a button that calls window.scrollTo({ top: 0, behavior: 'smooth' }). Show it only when the user has scrolled past a threshold (use another IntersectionObserver or a scroll event with throttle on the button visibility).

Q:Offset pagination vs cursor pagination — when to use which?

A: Offset (page/limit) is simpler but breaks when items are inserted or deleted between requests — you get duplicates or skipped items. Cursor-based (after: lastItemId) is more reliable for real-time feeds. Use offset for static data, cursor for dynamic.

Q:How would you preserve scroll position on back navigation?

A: Store the scroll position and loaded data in a cache (React context, zustand, or sessionStorage) before navigating away. On return, restore the data and scroll position with window.scrollTo(). Next.js also has experimental scroll restoration support.

Q:Can you implement this with Server Components?

A: The initial page can be a Server Component that fetches the first batch. Subsequent pages must be loaded client-side since they depend on scroll events. Use a hybrid approach: SSR the first page for fast initial load, then hydrate a Client Component for infinite scroll.

Ready to implement it yourself?

We've set up a paginated API and a starter component with a "Load More" button. Replace it with IntersectionObserver-based infinite scroll.

Built for developers, by developers. Happy coding! 🚀