All posts

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 10 forces 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) * limit

Behind 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 rating or price, 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 LIMIT and OFFSET, 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 →