Infinite Scroll vs Pagination vs Load More: Which to Build
Practice4 minInnovialabs editorial team
In short
For catalogs and search, where people need to find and return, use pagination or Load More with page URLs; infinite scroll suits feeds that people browse without a specific goal.
The short answer
- Pagination — when people look for something specific: a catalog, search results, an archive. They know where they are, can go back easily and share a link.
- Load More — a middle ground for catalogs and blogs: loading without navigation, but the user decides when to load, and the footer stays reachable.
- Infinite scroll — for feeds people browse without a specific goal: social feeds, collections, video.
If unsure, start with Load More plus a ?page=N URL: it is the option least likely to break navigation and SEO.
Comparison
| Pagination | Load More | Infinite scroll | |
|---|---|---|---|
| UX | control and orientation | control without reloads | least effort, easy to get lost |
| Footer | reachable | reachable | often unreachable |
| Performance | light pages | DOM grows on demand | DOM grows constantly |
| SEO | best option | good if page links exist | poor without separate URLs |
| Analytics | page views out of the box | needs events | needs events and scroll depth |
| Back navigation | works by itself | state must be saved | state must be saved |
SEO considerations
Search crawlers do not scroll and do not click buttons. To get the whole list indexed:
- each chunk needs its own URL (
/catalog?page=2); - the page must contain regular
<a href>links to the next page, even if JavaScript intercepts the click; - do not block paginated pages from indexing and do not point their canonical to page one — each page should be canonical to itself.
Cursor-based API pagination
OFFSET pagination is simple, but it gets slower on large tables, and when new rows are added, items get duplicated or skipped. A cursor solves both: the client sends the position of the last item it received.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
The API returns the list and a nextCursor — the last item's values encoded into a string. An empty nextCursor means there is no more data. Sorting needs an index on (created_at, id).
A cursor cannot jump straight to page 37, so classic numbered pagination usually keeps OFFSET, while cursors serve feeds and Load More.
Loading with Intersection Observer
Do not listen to the scroll event — use an Intersection Observer and an invisible "sentinel" element at the end of the list.
const sentinel = document.querySelector("#sentinel");
let cursor = null;
let loading = false;
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
const res = await fetch(`/api/posts?cursor=${encodeURIComponent(cursor ?? "")}`);
const { items, nextCursor } = await res.json();
renderItems(items);
cursor = nextCursor;
loading = false;
if (!nextCursor) io.disconnect();
}, { rootMargin: "400px" });
io.observe(sentinel);
rootMargin starts loading early so the user never hits an empty edge. For Load More, run the same code on a button click.
Preserving scroll position on back navigation
The most annoying issue: a user opens a product, presses Back and sees page one again. What helps:
- Update the URL with
history.replaceStateas chunks load (?page=3), so after returning you know how much to load. - Cache loaded data in
sessionStorageor a request cache (TanStack Query, SWR) and restore the list before the browser tries to restore scroll. - Save the scroll position before leaving and restore it after the list renders.
- In SPA frameworks, check how built-in scroll restoration behaves — it often works on its own when data is already cached.
Performance of long lists
- After a few hundred cards, turn on virtualization so only visible items stay in the DOM.
- Set image dimensions and use
loading="lazy". - Show skeletons while loading so the layout does not jump.