Pagination is one of the most-built UI components. The interview tests whether you understand the modes (page numbers vs cursor vs infinite scroll), the data fetching patterns, and the accessibility considerations.
The three modes
Page-number pagination
“Page 1 of 47.” Classic. Best for:
- Search results. Users expect to see “result 40 of 1,200” and page back to a listing they saw earlier. A common follow-up: when a filter or sort changes, reset to page 1 so the offset doesn’t point past the end of the new, smaller result set.
- Catalogs where users want to jump to specific pages. Shopping and documentation sites where someone bookmarks page 12 or types a page number directly. The tell that page numbers fit is when a slice of results needs a stable, linkable address.
- Content where total count is meaningful. “Page 1 of 47” itself carries information — how much is left, whether it’s worth continuing. This only pays off when the total is cheap to compute or already cached.
Cursor-based
“Show me 20 more results after id=abc123.” Best for:
- Infinite scroll. The UI only ever appends, so you just need “what comes after the last thing I showed.” A cursor pointing at the last item’s id — or a composite of sort key plus id — is all the client sends back.
- Live data where order may change (social feeds, notifications). New posts arrive between requests, so offset pagination would show duplicates or skip items as rows shift beneath the window. Interviewers probe this with “what happens when 10 new items are inserted while the user sits on page 2?” — a cursor anchored to a stable key answers it.
- Large datasets where COUNT is expensive. On a table with millions of rows, a full
COUNT(*)and a deepOFFSETboth force the database to walk huge numbers of rows. A cursor query reads only the page you asked for.
Infinite scroll
UI variation of cursor-based — load more as user scrolls. Best for:
- Browsing / exploration UX. When there’s no single answer the user is hunting for, they’re grazing, and stopping to click “next” breaks the flow. Discovery feeds and image galleries lean on this.
- Mobile feeds. Thumb-scroll is the native gesture on phones, and there’s no room for a page navigator. Pair it with scroll restoration so returning from a detail view doesn’t dump the user back at the top.
Worst for: tasks that require finding specific item later (no permalink to “page 27”).
Page-number implementation
const TOTAL = totalCount;
const PER_PAGE = 20;
const totalPages = Math.ceil(TOTAL / PER_PAGE);
const items = data.slice((page-1)*PER_PAGE, page*PER_PAGE);
Server-side: SELECT * FROM items LIMIT 20 OFFSET (page-1)*20.
Caveat: OFFSET pagination is slow on large tables and breaks under inserts.
Cursor-based implementation
Server returns items + cursor for next page:
{ items: [...], nextCursor: "id_after_last" }
Client requests next:
fetch(`/items?cursor=${nextCursor}&limit=20`)
Server: SELECT * FROM items WHERE id > cursor ORDER BY id LIMIT 20. Fast and stable under inserts.
Page navigator UI
Common pattern: First, Prev, [1] [2] [3] … [47], Next, Last.
For many pages, ellipsis to skip middle: 1, 2, 3, …, 22, 23, 24.
Page jump input: “Go to page _” for power users.
URL state
The current page belongs in the URL. ?page=3:
- Shareable — send page-3 link to a colleague
- Browser back works
- Refresh preserves position
Tools: nuqs for typed query-param management.
Infinite scroll triggering
Use IntersectionObserver:
const sentinelRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadMore();
});
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
Trigger before bottom (sentinel ~3 viewports up).
The “back from detail” problem
User scrolls infinite feed, taps an item, comes back. Where do they land? Top of feed (loses position, frustrating) or restored position?
Pattern: virtual list + scroll restoration. Save position on navigate-out; restore on navigate-in. React Router’s ScrollRestoration helps.
Loading states
- Initial load: skeleton or spinner
- Loading more (infinite): inline loader at bottom
- Page change: optionally fade or skeleton during fetch
Accessibility
<nav aria-label="Pagination">wrapper- Current page: aria-current=”page”
- Page links are real
<a>with hrefs (so middle-click opens new tab) - For infinite scroll: announce “Loaded 20 more” via aria-live
Common mistakes
- OFFSET pagination on large tables (slow).
LIMIT 20 OFFSET 100000still makes the database scan and throw away 100,000 rows before returning yours. Switch to keyset/cursor pagination (WHERE id > ?) once tables grow past a few thousand rows. - Page state not in URL (refresh loses position). If the page number lives only in component state, a refresh, a shared link, and the back button all drop the user to page 1. Keep it in the query string.
- Infinite scroll without scroll restoration. Tapping an item and hitting back re-mounts the feed at the top, forcing the user to re-scroll and re-fetch everything they already saw. Save and restore scroll position across navigation.
- “Page 1 of N” with N computed via expensive COUNT. A full
COUNT(*)on every page load is the hidden cost behind a harmless-looking total. Cache it, approximate it, or drop the total. - No loading state — UI looks frozen. With no skeleton or spinner, a slow fetch reads as a broken page and users mash the button. Show a placeholder the moment a request starts.
Frequently Asked Questions
Page numbers or infinite scroll?
Page numbers for catalogs and search. Infinite scroll for feeds. Hybrid (load more button) for the middle ground.
What about TanStack Query for pagination?
useInfiniteQuery handles cursor-based pagination beautifully. Manages cache, fetching, and state.
How do I show total count without expensive COUNT?
Approximate count (PostgreSQL pg_class.reltuples). Or skip the count entirely; show “many results” or “next page available.”
Useful next steps:
