Skip to main content

Cursor Pagination vs Offset: Which Is Better?

Cursor-based pagination and offset-based pagination both solve the same problem but with different trade-offs. Cursor pagination uses an opaque token (typically a timestamp or ID) to fetch the next batch of records; offset pagination uses a skip count. For social media feeds, financial data, or any rapidly-changing dataset, cursor pagination is more reliable because it avoids "phantom records" and skipped items when data shifts between requests.

Choosing between them depends on your data characteristics. Real-time datasets favor cursors; static snapshots favor offsets. Understanding both is essential for building scalable React applications.

Why Offset Pagination Breaks with Real-Time Data

Imagine a feed with 100 items, and you're viewing offset 0–19 (page 1). While you navigate to page 2 (offset 20–39), a new item is inserted at position 0. Now offsets shift: the old item at position 20 is now at position 21. You'll see a duplicate on page 2, or miss an item entirely.

This is the phantom record problem. Offset pagination assumes a static dataset; real-time datasets violate this assumption. Cursor pagination avoids this by using an immutable reference point (the last item's ID or creation time).

How Cursor Pagination Works

Cursor pagination returns an opaque cursor (encoded token) pointing to the last item in the response. To fetch the next page, the client sends this cursor back:

// API response includes a cursor
{
"items": [
{ "id": 1, "name": "Product A" },
{ "id": 2, "name": "Product B" }
],
"nextCursor": "eyJpZCI6IDJ9" // encoded { "id": 2 }
}

// Client requests next page with cursor
/api/products?cursor=eyJpZCI6IDJ9&limit=20

The backend decodes the cursor, finds where position was, and fetches the next N items after that position. If items were deleted or inserted, the position remains valid because it's keyed to the actual record.

Cursor Pagination Implementation in React

Implementing cursor pagination requires storing the cursor from each response:

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

async function fetchProductsWithCursor(cursor = null, limit = 20) {
const params = new URLSearchParams({ limit: limit.toString() });
if (cursor) params.append('cursor', cursor);
const response = await fetch(`/api/products?${params}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}

export function CursorPaginatedProducts() {
const [cursorStack, setCursorStack] = useState([null]); // null = first page
const currentPage = cursorStack.length - 1;
const currentCursor = cursorStack[currentPage];

const {
data = { items: [], nextCursor: null },
isLoading,
} = useQuery({
queryKey: ['products-cursor', currentCursor],
queryFn: () => fetchProductsWithCursor(currentCursor),
});

const goNext = () => {
if (data.nextCursor) {
setCursorStack([...cursorStack, data.nextCursor]);
}
};

const goPrev = () => {
if (currentPage > 0) {
setCursorStack(cursorStack.slice(0, -1));
}
};

return (
`<div>`
`<h2>`Products`</h2>`
{isLoading ? `<p>`Loading...`</p>` : null}
`<ul>`
{data.items.map((product) => (
`<li key={product.id}>{product.name}`</li>`
))}
`</ul>`
`<div>`
`<button onClick={goPrev} disabled={currentPage === 0}>`
Previous
`</button>`
`<button`
onClick={goNext}
disabled={!data.nextCursor || isLoading}
`>`
Next
`</button>`
`</div>`
`</div>`
);
}

Notice the cursorStack array: it stores every cursor we've visited, allowing backward navigation. This is a common pattern because HTTP APIs only provide the next cursor, not the previous one.

Cursor vs Offset: Comparison Table

AspectOffsetCursor
ImplementationNumeric skip countOpaque token
API SimplicityEasy, statelessRequires cursor encoding
Real-Time DataBreaks (phantom records)Resilient
Backward NavigationBuilt-in, freeRequires stack/history
Random AccessJump to page N directlyMust start from page 1
Large DatasetsSlow at high offsets (database scan)Consistent performance
Typical UseSearchable tables, archivesSocial feeds, notifications

Offset pagination scales linearly with the offset value; a database scan for offset 1,000,000 is slow. Cursor pagination stays fast because it uses an index seek, not a scan. For datasets exceeding 10 million items, cursor pagination is significantly faster (LinkedIn engineering blog, 2024).

When to Use Each

Use offset pagination if:

  • Your dataset is static or changes infrequently.
  • Users need random access (jump to page 50).
  • Your dataset is under 1 million items.
  • Your API already implements offset pagination.

Use cursor pagination if:

  • Your data is real-time (feeds, messages, notifications).
  • Performance at high offsets matters.
  • You can tolerate forward-only navigation.
  • Your dataset exceeds 10 million items.

Many modern APIs use both: offset for static searches, cursor for feeds.

Key Takeaways

  • Cursor pagination uses immutable tokens; offset pagination uses numeric positions.
  • Offset pagination fails with real-time data because new inserts shift positions.
  • Cursor pagination is faster on large datasets because databases can seek directly.
  • Implement cursor navigation with a stack to enable backward navigation.
  • Use offset for searchable data; use cursor for feeds and live data.

Frequently Asked Questions

What should I encode in a cursor?

Common choices: the ID of the last item, or the timestamp. For example, base64(JSON.stringify({ id: 42 })). Include enough info for the backend to find the next position deterministically.

Can I combine offset and cursor?

Yes. Offer both. Use offset for table navigation, cursor for infinite scrolling. Some APIs support offset with a cursor for the current page.

Does cursor pagination work with filtering?

Yes, but you must include the filter in the cursor or send it with every request. If you filter by status=active and an active item becomes inactive between requests, the cursor might skip it—so always include filter criteria in the API call.

How do I detect if there's a next page with cursor pagination?

The API returns nextCursor: null if no next page exists. Some APIs return a boolean flag instead.

Further Reading