Infinite Scroll
Infinite scroll is a UI pattern that loads additional content as the user approaches the end of a list.
Instead of clicking Next after every page, the user continues scrolling while the application fetches and appends another batch.
Render current items
↓
User approaches the end
↓
Request the next page
↓
Append unique items
↓
Repeat until no page remains
It is common in social feeds, activity timelines, product discovery, media galleries, and other exploratory experiences.
Core idea: Infinite scroll is paginated data with an automatic “load more” trigger.
1. Infinite Scroll vs Pagination vs Load More
All three patterns can use the same paginated API. The difference is how the next request begins.
| Pattern | Trigger | Best suited for |
|---|---|---|
| Pagination | User selects a page | Search results, tables, known positions |
| Load more | User presses a button | Browsing with user-controlled progress |
| Infinite scroll | A bottom sentinel becomes visible | Continuous discovery feeds |
Infinite scroll is not always the best experience. Traditional pagination is often better when users need to:
- reach a specific position;
- compare items across pages;
- use the browser back button predictably;
- access a footer;
- understand how many results remain.
A hybrid design can automatically load a few batches and still provide an accessible Load more button.
2. The Data Contract Comes First
An infinite list needs an API that returns bounded pages and tells the client how to continue.
Offset Pagination
GET /api/posts?offset=40&limit=20
{
"items": [],
"nextOffset": 60,
"hasMore": true
}
Offset pagination is simple and supports jumping to a position. However, inserts or deletions before the current offset can produce duplicates or skipped items.
Cursor Pagination
GET /api/feed?cursor=eyJpZCI6IjQyIn0&limit=20
{
"items": [],
"nextCursor": "eyJpZCI6IjYyIn0",
"hasMore": true
}
A cursor represents the continuation point chosen by the server. It is usually a better fit for frequently changing feeds.
| Offset | Cursor |
|---|---|
| Easy to implement and inspect | More stable when records are added or removed |
| Can jump to an arbitrary page | Usually moves sequentially |
| May drift on changing datasets | Requires a deterministic server-side order |
The server should use a stable ordering, often a timestamp plus a unique ID as a tie-breaker.
3. State Needed by the Client
A production list usually tracks more than the item array.
const [items, setItems] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
| State | Purpose |
|---|---|
items | All successfully loaded unique records |
nextCursor | Token for the next request |
status | idle, loading, success, or error |
error | Information needed for a retry UI |
hasMore | Prevents requests after the final page |
The UI must distinguish the initial load from loading another page:
Initial loading → full list skeleton
Next-page loading → small loader after existing items
4. Why IntersectionObserver Is Preferred
A manual scroll handler repeatedly calculates the viewport position:
window.scrollY + window.innerHeight >= document.body.scrollHeight;
Scroll events can fire very frequently. They also become fragile when the list is inside a container, images change item heights, or the page has multiple scrolling regions.
The IntersectionObserver API asks the browser to notify the application when a target intersects the viewport or a chosen scroll container.
List items
List items
List items
Sentinel <div> ← observer watches this element
When the sentinel approaches the viewport, the application requests another page.
5. React Implementation with a Sentinel
import { useCallback, useEffect, useRef, useState } from "react";
export default function PostFeed() {
const [items, setItems] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const sentinelRef = useRef(null);
const requestInFlightRef = useRef(false);
const controllerRef = useRef(null);
const loadMore = useCallback(async () => {
if (requestInFlightRef.current || !hasMore) return;
requestInFlightRef.current = true;
setStatus("loading");
setError(null);
const controller = new AbortController();
controllerRef.current = controller;
try {
const query = nextCursor
? `?cursor=${encodeURIComponent(nextCursor)}`
: "";
const response = await fetch(`/api/posts${query}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error("Could not load posts");
const page = await response.json();
setItems(current => mergeUniqueById(current, page.items));
setNextCursor(page.nextCursor ?? null);
setHasMore(Boolean(page.nextCursor));
setStatus("success");
} catch (requestError) {
if (requestError.name !== "AbortError") {
setError(requestError);
setStatus("error");
}
} finally {
requestInFlightRef.current = false;
if (controllerRef.current === controller) {
controllerRef.current = null;
}
}
}, [hasMore, nextCursor]);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: "300px 0px" }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadMore]);
return (
<section aria-busy={status === "loading"}>
{items.map(item => (
<article key={item.id}>
<h2>{item.title}</h2>
</article>
))}
{error && <button onClick={loadMore}>Try again</button>}
{status === "loading" && <p>Loading more posts…</p>}
{hasMore && <div ref={sentinelRef} aria-hidden="true" />}
{!hasMore && items.length > 0 && <p>You reached the end.</p>}
</section>
);
}
function mergeUniqueById(current, incoming) {
const byId = new Map(current.map(item => [item.id, item]));
for (const item of incoming) {
byId.set(item.id, item);
}
return [...byId.values()];
}
This example demonstrates the core pieces. In an application, fetching logic is often placed in a custom hook or a server-state library.
An AbortController should live for the lifetime of its request and be aborted when the component unmounts or the query changes. A reusable data hook makes that lifecycle easier to manage than a large UI component.
6. Prefetch Before the User Reaches the Bottom
The observer uses:
{ rootMargin: "300px 0px" }
This expands the observed area by 300 pixels vertically. The next request can begin shortly before the sentinel is visible.
Too late → user sees an empty wait at the bottom
Too early → data and bandwidth may be wasted
Balanced → next batch arrives near the time it is needed
Tune the distance using real network speed, item size, user behavior, and API latency rather than choosing an arbitrary large value.
7. Prevent Duplicate and Overlapping Requests
The sentinel may remain visible while a request is running. Multiple observer callbacks can therefore occur for the same cursor.
Use all relevant safeguards:
- an in-flight request guard;
- a
hasMorecheck; - a stable cursor or page parameter;
- request deduplication in the data layer;
- cancellation when the query or component changes.
if (isFetching || !hasNextPage) return;
Do not advance the cursor before a request succeeds. Otherwise a failed request can permanently skip a page.
Race Conditions
If filters change while a request is running, the old response must not be appended to the new result set.
Request A starts for category=books
User changes category to=music
Request B starts
Request A finishes last
↓
Discard or cancel A; never merge it into music results
An abort signal, query key, or request generation ID can prevent stale responses from winning.
8. Append Data Safely
Infinite scroll needs a functional state update because the next result depends on the items already stored.
setItems(current => [...current, ...page.items]);
Replacing the state removes earlier pages:
// Wrong for an append-only feed.
setItems(page.items);
Even with good pagination, the client should handle repeated IDs when the dataset can change between requests.
Use stable record IDs as React keys:
items.map(item => <PostCard key={item.id} post={item} />);
Array indexes are unsafe when records can be inserted, removed, or reordered.
9. Loading, Error, Empty, and End States
Every list state needs intentional UI.
| Situation | Recommended feedback |
|---|---|
| Initial request | Skeletons matching the list shape |
| Loading another page | Loader after the existing items |
| Empty result | Clear empty-state message and next action |
| Next-page error | Inline error with a retry button |
| Initial error | Page-level error with retry |
| Final page | “You reached the end” or a quiet end marker |
Do not remove already loaded content when only the next page fails. The user should be able to retry from the failure point.
10. Infinite Loading Is Not Virtualization
Infinite loading controls how much data is fetched. Virtualization controls how many rendered elements remain in the DOM.
Infinite loading
100 pages fetched → potentially 2,000 DOM rows
Virtualized infinite loading
100 pages fetched → only nearby visible rows rendered
As a long session continues, a non-virtualized feed can consume increasing memory and make style calculation, layout, and rendering slower.
Virtualization, also called windowing, renders only the visible region plus a small overscan buffer.
Consider it when measurements show that the growing DOM causes frame drops, slow rendering, or high memory use. Variable-height content, focus management, sticky elements, and scroll restoration make virtualization more complex, so it should solve an observed problem rather than be added automatically.
11. Images and Rendering Performance
Feeds commonly contain image-heavy cards. Reduce avoidable layout and network work:
- provide image width and height or an
aspect-ratio; - lazy-load offscreen images;
- serve responsive image sizes;
- use skeletons with stable dimensions;
- memoize expensive card computations only when profiling justifies it;
- avoid recreating every loaded page after a small update.
Reserve image space before it loads so cards below it do not jump and repeatedly move the sentinel.
12. Accessibility
Automatic content insertion can confuse keyboard and screen-reader users if focus, position, and loading changes are not communicated.
Provide:
- semantic list or feed structure;
- a keyboard-accessible Load more fallback;
aria-busy="true"while the feed is updating;- a restrained live-region message such as “20 more posts loaded”;
- a reliable way to reach content after the feed, including the footer;
- visible retry and end states;
- stable focus when new items are appended.
Do not move focus automatically to newly loaded items during normal scrolling. If the user activates a Load more button, preserve a logical focus and reading position.
The WAI-ARIA feed pattern can help specialized feeds, but native semantic HTML should be preferred where it provides the required structure. ARIA behavior must be tested with real assistive technologies.
13. Navigation and Scroll Restoration
A common failure looks like this:
User scrolls through 8 batches
↓
Opens an item
↓
Presses Back
↓
Returns to the top with only the first batch loaded
Possible restoration state includes:
{
pages,
pageParams,
scrollOffset,
filters,
sortOrder,
updatedAt
}
Cache loaded pages by a stable query key and restore the scroll offset only after enough content is rendered. Decide how long the cached feed remains valid so the user does not return to very stale data.
For shareable or search-oriented results, store filters and sorting in the URL even if the loaded pages themselves stay in an in-memory cache.
14. SEO and Discoverability
Pure infinite scroll can hide content from crawlers and create no stable link to a result position.
For public indexable content:
- provide real paginated URLs or server-rendered page boundaries;
- ensure important content is reachable without scrolling scripts;
- use ordinary links for item navigation;
- keep filtering and sorting represented in the URL;
- avoid relying on scrolling as the only discovery mechanism.
An authenticated social feed may prioritize continuity over indexing. A product search page usually needs stronger navigation, URL, and discoverability guarantees.
15. Production Architecture
Filter and sort state
↓
Stable query key
↓
UI sentinel → Page-fetch controller → Paginated API
↓
Cache pages and page parameters
↓
Flatten and deduplicate records
↓
Virtualized or regular renderer
↓
Loading / retry / end / accessibility feedback
A server-state library can provide page caching, request deduplication, retries, stale-data rules, and helpers such as an infinite query. The UI still owns observation, rendering, accessibility, and the user experience around each state.
16. Common Mistakes
| Mistake | Better approach |
|---|---|
| Fetching the entire dataset repeatedly | Request a bounded page with a cursor or offset |
| Calling the API on every scroll event | Observe a sentinel with IntersectionObserver |
| Allowing concurrent requests for one cursor | Add an in-flight guard or request deduplication |
| Incrementing a page before success | Advance only after receiving a valid page |
| Replacing earlier items | Append with a functional state update |
| Trusting the API never to repeat records | Merge by a stable unique ID where necessary |
| Rendering thousands of DOM nodes | Measure and introduce virtualization when needed |
| Hiding the footer forever | Add boundaries, a load-more control, or another footer access strategy |
| Losing progress after navigation | Cache pages and restore scroll position |
| Showing one generic spinner | Separate initial, next-page, error, empty, and end states |
| Ignoring keyboard and screen readers | Provide semantic structure, announcements, and user control |
17. Testing Checklist
Verify the list under realistic conditions:
- The initial page loads once.
- The observer requests the correct next cursor.
- A visible sentinel does not create duplicate requests.
- Repeated IDs do not create duplicate cards.
- The final page stops observation and requests.
- A next-page failure preserves current content and can be retried.
- Changing filters cancels or ignores stale responses.
- Slow networks do not show blank gaps.
- Navigation back restores content and position.
- Keyboard users can load and traverse more results.
- Screen readers receive useful, non-repetitive status feedback.
- Long sessions remain responsive and memory use stays acceptable.
Official References
- MDN: Intersection Observer API
- W3C WAI: Infinite Scrolling Feed Example
- web.dev: Virtualize Large Lists with react-window
- TanStack Query: Infinite Queries
Summary
Infinite scroll combines paginated fetching with a viewport trigger, then adds request control, rendering limits, restoration, and accessibility to remain reliable at scale.
Think of infinite scroll as a controlled loop, not an endless event handler:
Can the user continue?
↓ yes
Is another request already running?
↓ no
Request the exact next cursor
↓
Append unique results
↓
Render efficiently and announce the update
↓
Observe again only if another page exists
The loading trigger is the small part. Correct pagination, concurrency control, DOM size, navigation, and accessibility determine whether the experience works in production.