DEV Community

Cover image for Cursor vs Offset Pagination: A Frontend Engineer's Perspective in 2026
Abdul Halim
Abdul Halim

Posted on

Cursor vs Offset Pagination: A Frontend Engineer's Perspective in 2026

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
  ],
}));
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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)