Skip to main content

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.

PatternTriggerBest suited for
PaginationUser selects a pageSearch results, tables, known positions
Load moreUser presses a buttonBrowsing with user-controlled progress
Infinite scrollA bottom sentinel becomes visibleContinuous 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.

OffsetCursor
Easy to implement and inspectMore stable when records are added or removed
Can jump to an arbitrary pageUsually moves sequentially
May drift on changing datasetsRequires 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);
StatePurpose
itemsAll successfully loaded unique records
nextCursorToken for the next request
statusidle, loading, success, or error
errorInformation needed for a retry UI
hasMorePrevents 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.

About request cancellation

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 hasMore check;
  • 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.

SituationRecommended feedback
Initial requestSkeletons matching the list shape
Loading another pageLoader after the existing items
Empty resultClear empty-state message and next action
Next-page errorInline error with a retry button
Initial errorPage-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​

MistakeBetter approach
Fetching the entire dataset repeatedlyRequest a bounded page with a cursor or offset
Calling the API on every scroll eventObserve a sentinel with IntersectionObserver
Allowing concurrent requests for one cursorAdd an in-flight guard or request deduplication
Incrementing a page before successAdvance only after receiving a valid page
Replacing earlier itemsAppend with a functional state update
Trusting the API never to repeat recordsMerge by a stable unique ID where necessary
Rendering thousands of DOM nodesMeasure and introduce virtualization when needed
Hiding the footer foreverAdd boundaries, a load-more control, or another footer access strategy
Losing progress after navigationCache pages and restore scroll position
Showing one generic spinnerSeparate initial, next-page, error, empty, and end states
Ignoring keyboard and screen readersProvide 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​


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.