Cursor Pagination vs Offset Pagination: A Complete React Guide
August 27, 2026
- react
- pagination
- database
- frontend-architecture
- performance
Choose the right pagination strategy for your React application. We analyze cursor vs offset pagination, complete with database performance trade-offs, security, and React code examples.
The Quick Answer: Cursor Pagination vs Offset Pagination
The core difference between cursor pagination and offset pagination lies in how records are located in the database. Offset pagination uses a numerical offset to skip a specific number of records, whereas cursor pagination uses a unique identifier (a pointer or "cursor") from the last retrieved item to fetch the next set of records.
Offset pagination is ideal for applications requiring direct page navigation (e.g., jumping directly to Page 15) and where the underlying dataset is relatively small and static. Cursor pagination is the industry standard for real-time, high-write, or massive datasets (such as social media feeds and transaction logs) where infinite scrolling is preferred, and data is frequently appended or deleted.
Feature Offset Pagination Cursor Pagination Primary Use Case Admin dashboards, search results, small datasets Infinite scroll feeds, real-time activity streams Database Performance Degrades drastically on large datasets ($O(N)$ complexity) Highly performant and constant ($O(1)$ or $O(\log N)$ complexity) Data Consistency Prone to duplicate or skipped items on dynamic datasets Maintains strict consistency even with rapid inserts/deletes UI Navigation Numbered page navigation (Page 1, 2, 3...) "Load More" buttons or Infinite Scrolling Implementation Complexity Very simple on both frontend and backend More complex; requires index-optimized cursor generation
Key Takeaway: If you are building a dashboard where users need to jump to page 10, use offset pagination. If you are building an infinite list or a high-traffic activity feed, use cursor pagination to ensure system scalability.
Problem Definition: Why Pagination Design Matters
In modern frontend architecture, displaying large datasets efficiently is a critical challenge. If your database has millions of records, querying all of them at once will crash your backend, exhaust network bandwidth, and freeze the browser's main thread during rendering.
To solve this, we split datasets into manageable chunks. However, choosing the wrong strategy can introduce severe production bugs:
Data Drift / Duplication: On dynamic feeds, if a new item is inserted on page 1 while a user is on page 2, the last item of page 1 pushes down to page 2. In an offset model, the user sees a duplicate item.
Performance Degradation (Deep Paging): In relational databases, using
OFFSET 1000000 LIMIT 10forces the database to read, scan, and discard 1,000,000 rows before returning the 10 requested rows. This results in CPU spikes and slow response times.
For modern React developers, managing this state, maintaining a fluid UI, and keeping API response times under 100ms requires a firm grasp of both architectural paradigms.
Core Architecture of Both Systems
1. Offset Pagination Mechanics
Offset pagination relies on two parameters: limit (how many items to fetch) and offset (how many items to skip). The formula to calculate offset is:
offset = (pageNumber - 1) * limitBehind the scenes, the SQL execution looks like this:
SELECT * FROM articles
ORDER BY created_at DESC
LIMIT 10 OFFSET 50;The database engine uses a B-Tree index to find the order but must count and discard the first 50 records. As the offset reaches thousands or millions, this sequential scan becomes incredibly slow.
2. Cursor Pagination Mechanics
Instead of skipping a count of rows, cursor pagination requests records starting after a specific unique identifier (the cursor). The cursor must be sequential, unique, and indexable (such as an ID or timestamp).
The SQL execution shifts to a direct range comparison:
SELECT * FROM articles
WHERE id < 1492
ORDER BY id DESC
LIMIT 10;Because the database index can find the exact position of record 1492 in $O(\log N)$ time, it skips nothing and returns the next 10 rows instantly, regardless of how deep the pagination goes.
Below is a visual flow of both paradigms:
[Offset Pagination Flow]
Client (page 3, limit 10) ---> DB scans & discards 20 rows ---> Returns next 10 rows
[Cursor Pagination Flow]
Client (cursor: "id_120", limit 10) ---> DB jumps to "id_120" via index ---> Returns next 10 rows
Implementation in React
Let's look at how to implement both patterns in a React application. We will build custom React components with complete state management and hooks.
Example 1: Implementing Offset Pagination in React
This implementation handles traditional numbered pagination, maintaining standard query states and page tracking.
import React, { useState, useEffect } from 'react';
interface Article {
id: number;
title: string;
}
interface PaginatedResponse {
data: Article[];
totalPages: number;
}
export function OffsetPaginationComponent() {
const [articles, setArticles] = useState<Article[]>([]);
const [currentPage, setCurrentPage] = useState<number>(1);
const [totalPages, setTotalPages] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
const limit = 10;
useEffect(() => {
async function fetchArticles() {
setLoading(true);
try {
const offset = (currentPage - 1) * limit;
const response = await fetch(`/api/articles?limit=${limit}&offset=${offset}`);
const result: PaginatedResponse = await response.json();
setArticles(result.data);
setTotalPages(result.totalPages);
} catch (error) {
console.error('Failed to fetch data', error);
} finally {
setLoading(false);
}
}
fetchArticles();
}, [currentPage]);
return (
<div className="pagination-container">
<h3>Offset-Based Articles</h3>
{loading ? (
<p>Loading...</p>
) : (
<ul>
{articles.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
<div className="pagination-controls">
<button
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
disabled={currentPage === 1 || loading}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
disabled={currentPage === totalPages || loading}
>
Next
</button>
</div>
</div>
);
}Example 2: Implementing Cursor Pagination (Infinite Scroll) in React
For cursor pagination, we need to store a list of accumulated items and track the nextCursor. We will use the browser's IntersectionObserver API to build a performant infinite-scrolling interface.
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface Article {
id: number;
title: string;
}
interface CursorResponse {
data: Article[];
nextCursor: string | null;
}
export function CursorPaginationComponent() {
const [articles, setArticles] = useState<Article[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const observer = useRef<IntersectionObserver | null>(null);
const limit = 10;
const fetchArticles = useCallback(async (currentCursor: string | null) => {
if (loading) return;
setLoading(true);
try {
const queryParam = currentCursor ? `&cursor=${encodeURIComponent(currentCursor)}` : '';
const response = await fetch(`/api/articles?limit=${limit}${queryParam}`);
const result: CursorResponse = await response.json();
setArticles((prev) => [...prev, ...result.data]);
setNextCursor(result.nextCursor);
setHasMore(result.nextCursor !== null);
} catch (error) {
console.error('Failed to load cursor data', error);
} finally {
setLoading(false);
}
}, [loading]);
// Initial render load
useEffect(() => {
fetchArticles(null);
}, []);
// Callback ref to observe the last element in the list
const lastElementRef = useCallback((node: HTMLLIElement | null) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
fetchArticles(nextCursor);
}
});
if (node) observer.current.observe(node);
}, [loading, hasMore, nextCursor, fetchArticles]);
return (
<div className="infinite-scroll-container">
<h3>Cursor-Based Infinite Feed</h3>
<ul>
{articles.map((item, index) => {
const isLastElement = index === articles.length - 1;
return (
<li
key={item.id}
ref={isLastElement ? lastElementRef : null}
style={{ padding: '20px', borderBottom: '1px solid #ccc' }}
>
{item.title}
</li>
);
})}
</ul>
{loading && <p>Loading more articles...</p>}
{!hasMore && <p>No more items to display.</p>}
</div>
);
}Trade-offs & Implementation Analysis
When should you use Offset Pagination?
Direct Page Navigation Needed: Perfect for admin tables, invoices, audit trails, and directories where users expect numerical page navigation (e.g., "Go to Page 4").
Small to Medium Datasets: If your dataset is guaranteed to remain under 50,000-100,000 records, the overhead of offset is negligible.
Complex Client-Side Multi-Sorting: If users are sorting by multiple client-controlled criteria concurrently, offset pagination handles arbitrary ordering with fewer backend changes.
When should you NOT use Offset Pagination?
Dynamic Data with Frequent Inserts: Avoid if items are added frequently (e.g., social timelines). Users will experience duplicates or skip items when clicking "Next".
Massive Scale: Avoid if table sizes reach millions of rows; database lookup performance will tank.
When should you use Cursor Pagination?
Infinite Scrolling & Dynamic Timelines: Essential for continuous scroll feeds (e.g., e-commerce, media hubs, social feeds) where data constantly shifts.
Massive Datasets: Essential when scaling past hundreds of thousands of rows where execution performance must stay constant ($O(1)$ lookup complexity).
When should you NOT use Cursor Pagination?
Arbitrary Jump Navigation: If your application requires jumping directly to arbitrary pages (e.g., page 55), cursor models cannot accomplish this without progressively reading all preceding records.
Complex Multi-Column Sorting: If you allow sorting by fields that contain non-unique values without fallback columns, setting up cursors becomes exceptionally difficult.
Common Pitfalls and Architectural Anti-Patterns
Anti-Pattern 1: Leaking Internal IDs directly as Cursors. Exposing raw Database IDs in cursors can expose business metrics (e.g., order rate, total users) and invites automated scrapers. Solution: Base64-encode cursors or use a secure cryptographic token.
Anti-Pattern 2: Non-unique keys in Sort Cursors. If you sort by a field like
ratingorprice, multiple items will have the same value, causing skipped items during page transitions. Solution: Use a composite cursor combining the sort value with a unique ID (e.g.,[price, id]).Anti-Pattern 3: Forgetting to clean up IntersectionObservers. Failing to disconnect observers can cause memory leaks and multiple simultaneous API calls in React. Always disconnect or use clean callback references.
Performance, Security, and Scalability Considerations
Performance
On the client, infinite scroll with cursor pagination can lead to DOM bloat. If a user scrolls through 1,000 items, there are 1,000 complex DOM nodes in memory. To optimize memory consumption, integrate virtualization engines like react-window or TanStack Virtual to only render items currently within the viewport.
Security
Always sanitize parameters like limit and offset on the server to prevent SQL Injection or Denial of Service (DoS) attacks via oversized limits (e.g., requesting limit=1000000). Implement maximum limits, such as a hard ceiling of 100 records per request.
Frequently Asked Questions
What is cursor pagination?
Cursor pagination is a technique where the frontend requests the next chunk of data using a unique pointer from the last element of the previous page, allowing constant $O(1)$ lookup performance in the database.
Why is offset pagination slow on deep pages?
Offset pagination forces the database engine to perform sequential scans of previous rows, counting and discarding them, which results in linear performance degradation ($O(N)$ execution complexity) as page depth increases.
Can I use cursor pagination with numerical page links?
No, cursor pagination does not support numbered navigation directly because it does not count the total rows in advance. It only knows whether a "next" or "previous" page exists.
Key Takeaways
Choose pagination based on consumer behavior: Offset for structured, search-focused UIs; cursor for exploration-focused feeds.
Offset pagination uses
LIMITandOFFSET, leading to database degradation on large scale datasets.Cursor pagination scales seamlessly ($O(1)$ index scans) but cannot jump to specific pages.
In React, combine cursor pagination with IntersectionObserver for clean infinite scrolls, and virtualize large lists to protect DOM performance.
Related Articles
View all posts →Hydration Errors Explained: The Ultimate Guide for Developers
Demystify the infamous React hydration error. Learn why server-client mismatches happen and discover clear, beginner-friendly strategies to fix them today.
Why is useMemo Making My App Slower? The Hidden Costs of Premature React Optimization
Many developers use useMemo blindly to optimize performance, only to find their applications running slower. This comprehensive guide breaks down the hidden costs of React memoization and when to avoid it.
Controlled and Uncontrolled Components in React: The Definitive Guide
Master the differences between controlled and uncontrolled components in React. Learn when to use state or refs to handle form data, optimize performance, and avoid common UI bugs.