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.
Table of Contents
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.
Pagination vs Infinite Scroll
Before building, understand when to use each approach:
| Pagination | Infinite Scroll | |
|---|---|---|
| UX | User clicks "Next" / page numbers | Content loads automatically on scroll |
| Best for | Search results, data tables, e-commerce | Social feeds, image galleries, news |
| SEO | Better — each page has a unique URL | Harder — content is dynamically loaded |
| Memory | Fixed — only one page in DOM | Grows — 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.
Backend API Design
The API returns paginated data with metadata the client needs to know whether to keep fetching.
Endpoint
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/posts?page=1&limit=10 | Fetch a page of posts |
Response Shape
{ "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
// 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.
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
Create an observer
Instantiate a new IntersectionObserver with a callback function and optional config (root, rootMargin, threshold).
Observe a sentinel element
Place an invisible div at the bottom of your list. Tell the observer to watch it.
Callback fires
When the sentinel scrolls into view (intersects the viewport), the callback fires with an array of IntersectionObserverEntry objects.
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.
// 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.
Frontend Implementation
The implementation has three parts: state management, the IntersectionObserver hook, and the UI. Let's build each one.
Step 1: Types & State
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
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.
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
"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.
Data Flow
Here's how data flows through the application from initial load through subsequent page fetches:
Component mounts
useEffect fires fetchPosts(1). Loading state is set to true. The API returns the first 10 posts with meta.hasMore = true.
Posts render, sentinel is in DOM
The 10 posts render. The sentinel div sits below them. The IntersectionObserver starts watching it.
User scrolls down
As the user scrolls, the sentinel approaches the viewport. With rootMargin: '200px', the observer fires 200px early.
Observer callback fires
The callback checks: isIntersecting? Yes. loading? No. hasMore? Yes. It increments the page and calls fetchPosts(2).
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.
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.
Performance Considerations
Infinite scroll can become a performance problem if not handled carefully. Here's what's in place and what you could add:
IntersectionObserver over scroll events
Runs asynchronously off the main thread. No debouncing needed. Much more efficient than listening to scroll events.
Loading guard
The observer callback checks `loading` before fetching. This prevents duplicate requests when the sentinel is visible during a fetch.
Append-only state updates
Using setPosts(prev => [...prev, ...newPosts]) avoids replacing the entire array. React can diff efficiently.
Server-side latency simulation
The API has a 500ms delay to simulate real network conditions. Your UI should handle this gracefully with a spinner.
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.
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.
Request deduplication
Use AbortController to cancel in-flight requests when a new one starts. Prevents race conditions with rapid scrolling.
Cursor-based pagination
Replace page/offset with a cursor (last item ID). More reliable when items are added or removed between requests.
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(); }, []);
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! 🚀