We talk about pagination as if it's purely a backend concern – the database does the heavy lifting, the API returns pages, and the frontend just renders them. But in 2026, that mental model is outdated. The frontend now owns more of the data-fetching lifecycle than ever: server components prefetch, client caches hydrate, optimistic updates mutate, and streaming responses trickle in chunk by chunk.
The choice between cursor pagination and offset pagination has real consequences for how you write your React components, how your cache behaves, how scroll feels on the phone, and what happens when a user navigates back. This post is about those tradeoffs – from the frontend seat.
The Landscape Has Changed
A few things are different in 2026 that make this conversation more nuanced than it was three or four years ago:
- React Server Components are mainstream. Data fetching happens on the server in many apps, which shifts where pagination state lives and how navigation works.
- TanStack Query is the de-facto standard for client-side async state, with first-class infinite query support baked in.
- The "infinite scroll vs pagination" debate is mostly settled — infinite scroll wins for feeds and content-heavy apps; numbered pages win for dense data tables. Your pagination strategy should serve that decision, not fight it.
- LLM-powered search and filtering are becoming common, and those use cases have their own quirks around pagination stability.
- Edge caching and CDN-level pagination mean that certain offset-paginated responses can be cached by URL – a genuine advantage offset still holds.
What Frontend Engineers Actually Care About
When you strip away the SQL theory, here's what the pagination choice actually affects on the frontend:
1. Cache Key Design
With offset pagination, the cache key is simple and predictable: posts?page=3&limit=20. Every page is independently cacheable by URL — your CDN loves this. TanStack Query, SWR, and Apollo all handle this naturally.
// Offset — clean, predictable cache keys
const { data } = useQuery({
queryKey: ['posts', { page, limit }],
queryFn: () => fetchPosts({ page, limit }),
});
With cursor pagination, the cache key depends on the previous response. You can't know page 3's key without having fetched pages 1 and 2. This is why TanStack Query has a dedicated useInfiniteQuery API — the standard useQuery just doesn't model this shape.
// Cursor — requires infinite query primitives
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor ?? undefined,
});
The tradeoff: Offset gives you individually shareable, bookmarkable, CDN-cacheable page URLs. Cursor gives you a stable, append-only data structure that's ideal for infinite scroll but harder to deep-link into.
2. Navigation and the Back Button
This is where offset pagination has a quiet, underappreciated advantage: it maps onto URL state naturally.
/products?page=4&sort=price_asc
A user on page 4 can bookmark it, share it, or hit back and land exactly where they were. The server can render the correct page server-side with no hydration complexity.
With cursor pagination, you'd need to store the cursor in the URL too:
/products?cursor=eyJpZCI6MTIzfQ&sort=price_asc
That works – but it's opaque to users, it breaks if the cursor expires server-side (a real issue with time-limited cursors), and it requires your RSC or SSR layer to be cursor-aware. More importantly, if the user wants to share page 4 with a colleague, that cursor only means "after item 123" — not "the 4th page of results sorted by price." These are semantically different things, and users don't distinguish between them until something breaks.
The 2026 wrinkle: With React Server Components and the App Router, navigating back often re-fetches rather than restoring scroll position. Offset pagination + URL state handles this correctly out of the box. Cursor-based infinite scroll requires extra work — typically saving scroll position to sessionStorage and restoring it, or using the experimental_useOptimistic pattern with a pre-fetched cache.
3. Optimistic Updates and Mutations
Suppose a user creates a new post. With offset pagination:
- The new post appears at position 1 in the sorted order.
- Pages 1 through N all shift.
- Any cached pages are now stale.
- You invalidate all queries with
queryClient.invalidateQueries(['posts']). - The user sees a brief loading state before the refetch.
With cursor pagination:
- You prepend the new post optimistically to the top of the feed.
- The cursor chain stays intact — future "load more" fetches are unaffected.
- You can invalidate only the first page while keeping the rest of the cache valid.
// Optimistic prepend with cursor pagination
queryClient.setQueryData(['posts'], (old) => ({
...old,
pages: [
{ data: [newPost, ...old.pages[0].data], pagination: old.pages[0].pagination },
...old.pages.slice(1),
],
}));
Cursor pagination is genuinely better here. Offset pagination invalidation is coarse — you either refetch everything or accept a stale cache. Cursor pagination lets you surgically update the head of the feed while trusting the tail.
4. Scroll Performance and Virtualization
For long feeds rendered in the browser, you'll eventually reach for a virtualizer — TanStack Virtual, React Window, or similar. The pagination strategy affects how you feed data into the virtualizer.
With offset pagination, you know the total count upfront. You can pre-size the scroll container and render placeholder rows for unloaded pages:
// You can pre-allocate rows because you know totalCount
const rowVirtualizer = useVirtualizer({
count: totalCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
});
With cursor pagination, you don't know the total. You render what you have, append more as the user scrolls, and use a sentinel element to trigger the next fetch:
// Sentinel-based infinite scroll
const { ref: sentinelRef } = useIntersectionObserver({
onChange: (inView) => { if (inView && hasNextPage) fetchNextPage(); },
});
// At the bottom of your list:
<div ref={sentinelRef} />
Neither is strictly better — they serve different UX patterns. But in 2026, the intersection observer + cursor pattern is so well-supported and so well-understood that it should be your default for any feed-like UI.
The 2026 Tradeoff Matrix
| Concern | Offset | Cursor |
|---|---|---|
| URL shareability / deep linking | ✅ Natural | ⚠️ Possible but awkward |
| Infinite scroll UX | ⚠️ Requires workarounds | ✅ Native fit |
| Numbered page UI ("Page 3 of 47") | ✅ Trivial | ❌ Not supported |
| Back-button behavior (RSC/App Router) | ✅ Works with URL state | ⚠️ Needs extra work |
| CDN-level page caching | ✅ URL is the cache key | ❌ Cursor is opaque |
| Optimistic prepend on new items | ⚠️ Invalidates full cache | ✅ Surgical cache update |
| Stable results under concurrent writes | ❌ Skips / duplicates | ✅ Stable |
| TanStack Query / SWR compatibility | ✅ useQuery
|
✅ useInfiniteQuery
|
| LLM search result pagination | ✅ Page-level caching useful | ⚠️ Stream-based, different model |
| Large dataset performance (> 1M rows) | ❌ Deep offset is slow | ✅ O(log N) always |
| SSR / RSC initial render | ✅ Simple prop passing | ⚠️ Needs cursor threading |
The Emerging Middle Ground: Hybrid Pagination
A pattern gaining traction in 2026 is the hybrid approach — especially in apps that have both a data-table view and a feed view of the same resource.
The idea: expose cursor pagination at the API level (for stability and performance), but compute and expose a virtual page number on top of it. The frontend stores a map of pageNumber → cursor:
// Client-side page-to-cursor map
const cursorMap = useRef<Map<number, string>>(new Map([[1, '']]));
function goToPage(pageNumber: number) {
const cursor = cursorMap.current.get(pageNumber);
if (cursor !== undefined) {
setCurrentCursor(cursor);
}
// If not in map yet, must paginate forward to reach it
}
// After each page fetch, store the cursor for the next page
function onPageFetched(pageNumber: number, nextCursor: string) {
cursorMap.current.set(pageNumber + 1, nextCursor);
}
This is more complex but gives you the best of both worlds: server-side cursor performance and stability, with a client-side illusion of numbered pages. Libraries like Relay have done a version of this for years; it's increasingly practical with modern client-side state tooling.
What About React Server Components Specifically?
RSC changes the calculus a bit. When the server renders the initial page, it can use a cursor or offset equally well — the component is async and can await either query. But navigation changes things.
With offset + URL params, navigation is a first-class RSC operation:
// app/posts/page.tsx
export default async function PostsPage({ searchParams }) {
const page = Number(searchParams.page ?? 1);
const posts = await fetchPosts({ page, limit: 20 });
return <PostList posts={posts} />;
}
Navigating to ?page=4 re-runs the server component with new params — clean, cacheable at the RSC level, and SSR-friendly.
With cursors and RSC, you need the cursor in the URL or in a server-side session. The URL is simpler and more robust:
// app/posts/page.tsx
export default async function PostsPage({ searchParams }) {
const cursor = searchParams.cursor ?? null;
const { posts, nextCursor } = await fetchPosts({ cursor, limit: 20 });
return <PostList posts={posts} nextCursor={nextCursor} />;
}
Both work. But the cursor approach requires your client to manage and push the cursor into the URL, which pushes complexity back toward the client. For RSC-heavy apps, offset pagination for paginated pages and cursor pagination for client-rendered feeds is a reasonable split.
Practical Recommendation for 2026
The decision tree is simpler than it used to be:
Use cursor pagination if:
- The UI is a feed, timeline, or "load more" pattern
- The dataset grows fast or has high write concurrency
- You're building a mobile-first or infinite-scroll experience
- Correctness under concurrent updates matters
Use offset pagination if:
- The UI is a data table with numbered pages and sorting controls
- Users need to deep-link to or bookmark a specific page
- You need CDN-level caching of paginated responses
- The dataset is bounded and relatively static
Use the hybrid approach if:
- You have both UX patterns on the same data
- You're building a high-quality admin interface with a public-facing feed
The old "just use offset, it's simple" advice is actively harmful at scale. But the opposite — "always use cursor, it's correct" — ignores real frontend constraints around navigation, SSR, and URL state that still matter in 2026.
Pick based on your UI pattern first, your dataset characteristics second, and your caching strategy third. The backend team will thank you for having a clear answer.
Top comments (0)