Skip to content
Unverified — AI-generated content. Help verify this page

API Pagination Patterns

Every collection endpoint must be paginated. Without pagination, a single GET /orders request can return millions of rows, saturate your database connection pool, exhaust server memory, and blow through your client's bandwidth budget. Pagination is not a nice-to-have — it is a reliability requirement.

But not all pagination is equal. The three major strategies — offset, cursor, and keyset — make fundamentally different trade-offs between simplicity, performance, and consistency. Choosing wrong can mean the difference between 5ms and 5-second page loads at scale.

The Three Strategies at a Glance

StrategyQuery CostPage JumpingConsistencyComplexity
OffsetO(offset + limit)YesUnstableLow
CursorO(limit)NoStableMedium
KeysetO(limit)NoStableMedium

Offset-Based Pagination

The simplest approach: skip N rows, return the next M.

How It Works

GET /api/orders?offset=0&limit=20    → rows 1-20
GET /api/orders?offset=20&limit=20   → rows 21-40
GET /api/orders?offset=40&limit=20   → rows 41-60

Implementation

typescript
// Express.js handler
app.get('/api/orders', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
  const offset = parseInt(req.query.offset as string) || 0;

  const [orders, totalCount] = await Promise.all([
    db.query(
      'SELECT * FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2',
      [limit, offset]
    ),
    db.query('SELECT COUNT(*) FROM orders')
  ]);

  res.json({
    data: orders.rows,
    pagination: {
      offset,
      limit,
      total: parseInt(totalCount.rows[0].count),
      has_more: offset + limit < parseInt(totalCount.rows[0].count)
    }
  });
});

Response Format

json
{
  "data": [
    { "id": "order_41", "status": "shipped", "created_at": "2026-03-15T10:00:00Z" },
    { "id": "order_42", "status": "pending", "created_at": "2026-03-15T09:30:00Z" }
  ],
  "pagination": {
    "offset": 40,
    "limit": 20,
    "total": 1523,
    "has_more": true
  }
}

The Performance Problem

Offset pagination uses SQL OFFSET, which does not skip rows — it reads and discards them:

sql
-- Page 1: scans 20 rows
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;

-- Page 50: scans 1,000 rows, returns 20
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 980;

-- Page 5,000: scans 100,000 rows, returns 20
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 99980;

The database must sort and scan all rows up to offset + limit for every request. At high offsets, this becomes progressively slower.

Offset     |  Query Time (1M rows, PostgreSQL)
-----------+-----------------------------------
0          |  2ms
1,000      |  5ms
10,000     |  25ms
100,000    |  180ms
500,000    |  900ms
900,000    |  1,600ms

The Consistency Problem

Offset pagination is unstable when data changes between page requests:

State at time T0: [A, B, C, D, E, F, G, H]

Client requests page 1 (offset=0, limit=4): [A, B, C, D]

New item X is inserted at position 1.

State at time T1: [X, A, B, C, D, E, F, G, H]

Client requests page 2 (offset=4, limit=4): [D, E, F, G]
                                              ↑ D appears AGAIN

Item D is returned on both pages. If an item is deleted between requests, a different item is skipped entirely. For frequently changing datasets, this means consumers see duplicates or miss records.

WARNING

Offset pagination is acceptable for small, mostly-static datasets (admin dashboards, configuration lists) and use cases that require page jumping (showing "Page 3 of 12"). For anything else, use cursor or keyset pagination.

When Offset Is Still the Right Choice

  • UI requires page numbers — "Go to page 7 of 12" is only possible with offset
  • Total count is needed — some UIs show "Showing 41-60 of 1,523 results"
  • Dataset is small — under 10,000 rows, the performance difference is negligible
  • Data is rarely modified — reference data, catalogs, configuration

Cursor-Based Pagination

Cursor pagination uses an opaque token (the "cursor") that encodes the position in the result set. The server knows how to decode the cursor to resume from where the previous page ended.

How It Works

GET /api/orders?limit=20
→ { data: [...], pagination: { next_cursor: "eyJpZCI6Im9yZGVyXzIwIn0=" } }

GET /api/orders?cursor=eyJpZCI6Im9yZGVyXzIwIn0=&limit=20
→ { data: [...], pagination: { next_cursor: "eyJpZCI6Im9yZGVyXzQwIn0=" } }

GET /api/orders?cursor=eyJpZCI6Im9yZGVyXzQwIn0=&limit=20
→ { data: [...], pagination: { next_cursor: null, has_more: false } }

Cursor Encoding

The cursor is typically a base64-encoded JSON object containing the sort key values of the last item:

typescript
interface CursorPayload {
  id: string;
  created_at: string;  // Include all sort key fields
}

function encodeCursor(payload: CursorPayload): string {
  return Buffer.from(JSON.stringify(payload)).toString('base64url');
}

function decodeCursor(cursor: string): CursorPayload {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}

TIP

Use base64url encoding (not standard base64) for cursors. Standard base64 includes +, /, and = characters that need URL encoding. base64url uses -, _ instead and is safe in query parameters.

Implementation

typescript
app.get('/api/orders', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
  const cursor = req.query.cursor as string | undefined;

  let query = 'SELECT * FROM orders';
  const params: unknown[] = [];

  if (cursor) {
    const decoded = decodeCursor(cursor);
    // Keyset condition: resume after the cursor position
    query += ` WHERE (created_at, id) < ($1, $2)`;
    params.push(decoded.created_at, decoded.id);
  }

  query += ` ORDER BY created_at DESC, id DESC LIMIT $${params.length + 1}`;
  params.push(limit + 1); // Fetch one extra to detect has_more

  const result = await db.query(query, params);
  const hasMore = result.rows.length > limit;
  const data = hasMore ? result.rows.slice(0, limit) : result.rows;

  const lastItem = data[data.length - 1];
  const nextCursor = hasMore && lastItem
    ? encodeCursor({ id: lastItem.id, created_at: lastItem.created_at })
    : null;

  res.json({
    data,
    pagination: {
      next_cursor: nextCursor,
      has_more: hasMore
    }
  });
});

Why Cursors Are Fast

The cursor translates to a WHERE clause that uses an index, not an OFFSET that scans and discards rows:

sql
-- Cursor pagination: always fast, regardless of "page number"
SELECT * FROM orders
WHERE (created_at, id) < ('2026-03-15T10:00:00Z', 'order_980')
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- With a composite index on (created_at DESC, id DESC),
-- this is an index seek + 20 row fetch = O(limit)

Supporting the Index

sql
-- This index makes cursor pagination fast
CREATE INDEX idx_orders_cursor
ON orders (created_at DESC, id DESC);

Keyset Pagination

Keyset pagination is the underlying mechanism that makes cursor pagination fast. The difference is that keyset pagination uses visible, meaningful values (like IDs and timestamps) rather than opaque encoded cursors.

How It Works

GET /api/orders?limit=20
GET /api/orders?after_id=order_20&after_created_at=2026-03-15T09:00:00Z&limit=20
GET /api/orders?after_id=order_40&after_created_at=2026-03-14T15:00:00Z&limit=20

Implementation

python
# FastAPI + SQLAlchemy example
from fastapi import FastAPI, Query
from sqlalchemy import select, and_, tuple_
from datetime import datetime

app = FastAPI()

@app.get("/orders")
async def list_orders(
    limit: int = Query(20, ge=1, le=100),
    after_id: str | None = Query(None),
    after_created_at: datetime | None = Query(None),
):
    query = select(Order).order_by(
        Order.created_at.desc(), Order.id.desc()
    )

    if after_id and after_created_at:
        # Keyset condition using tuple comparison
        query = query.where(
            tuple_(Order.created_at, Order.id) <
            tuple_(after_created_at, after_id)
        )

    query = query.limit(limit + 1)
    results = await db.execute(query)
    orders = results.scalars().all()

    has_more = len(orders) > limit
    data = orders[:limit]

    return {
        "data": [order.to_dict() for order in data],
        "pagination": {
            "has_more": has_more,
            "next_params": {
                "after_id": data[-1].id,
                "after_created_at": data[-1].created_at.isoformat()
            } if has_more and data else None
        }
    }

Cursor vs Keyset: When to Use Which

FactorCursor (opaque)Keyset (visible)
Client simplicitySimpler (just pass the token)Must track multiple fields
Server flexibilityCan change encoding without breaking clientsChanging sort fields breaks clients
DebuggingHarder (opaque tokens)Easier (visible values)
BookmarkingNot meaningfulCan construct URLs manually
SecurityHides implementation detailsExposes sort key values

TIP

For public APIs, prefer opaque cursors — they give you the freedom to change the underlying implementation without a breaking change. For internal APIs, keyset pagination with visible parameters is simpler and easier to debug.

Multi-Column Sorting with Pagination

When paginating with multiple sort criteria, the keyset condition must be a tuple comparison:

sql
-- Sort by status ASC, then created_at DESC, then id DESC
-- Keyset condition for "next page after (shipped, 2026-03-15, order_50)":

SELECT * FROM orders
WHERE (status, created_at DESC, id DESC) > ('shipped', '2026-03-15', 'order_50')
ORDER BY status ASC, created_at DESC, id DESC
LIMIT 20;

In practice, tuple comparison in SQL can be tricky. The expanded form:

sql
WHERE status > 'shipped'
   OR (status = 'shipped' AND created_at < '2026-03-15')
   OR (status = 'shipped' AND created_at = '2026-03-15' AND id < 'order_50')

WARNING

Multi-column keyset pagination requires a composite index that matches the sort order exactly. Without it, the database falls back to a full table scan. Always verify with EXPLAIN ANALYZE.

The Total Count Problem

Offset pagination naturally provides a total count. Cursor/keyset pagination does not — and computing total count can be expensive.

Strategies

StrategyCostAccuracyImplementation
Exact countHigh (COUNT(*) on every request)PerfectSELECT COUNT(*) FROM orders WHERE ...
Cached countLow (periodic refresh)Stale (seconds to minutes)Redis counter, updated by triggers or background job
Estimated countVery lowApproximateSELECT reltuples FROM pg_class WHERE relname = 'orders'
No countZeroN/AReturn has_more only; show "Load more" instead of page numbers
typescript
// PostgreSQL fast estimate (within ~10% accuracy for large tables)
async function estimateCount(table: string): Promise<number> {
  const result = await db.query(
    `SELECT reltuples::bigint AS estimate
     FROM pg_class
     WHERE relname = $1`,
    [table]
  );
  return result.rows[0]?.estimate || 0;
}

TIP

For most APIs, has_more: true/false is sufficient. The "infinite scroll" UI pattern has trained users to not expect total counts. Only compute exact counts when the UI genuinely requires page numbers.

Response Format Standards

Standard Cursor Pagination Response

json
{
  "data": [
    { "id": "order_42", "status": "shipped" },
    { "id": "order_41", "status": "pending" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6Im9yZGVyXzQxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDMtMTRUMTU6MDA6MDBaIn0",
    "prev_cursor": "eyJpZCI6Im9yZGVyXzQyIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDMtMTVUMTA6MDA6MDBaIn0",
    "has_more": true
  }
}

Standard Offset Pagination Response

json
{
  "data": [
    { "id": "order_42", "status": "shipped" },
    { "id": "order_41", "status": "pending" }
  ],
  "pagination": {
    "offset": 40,
    "limit": 20,
    "total": 1523,
    "has_more": true
  }
}

Some APIs use the HTTP Link header for pagination instead of response body fields:

Link: <https://api.example.com/orders?cursor=abc123>; rel="next",
      <https://api.example.com/orders?cursor=xyz789>; rel="prev"

GitHub uses this pattern. It keeps the response body clean but requires consumers to parse headers.

Performance Benchmark

Testing with PostgreSQL 15, 10 million rows, indexed on (created_at DESC, id DESC):

Strategy     | Page 1    | Page 100  | Page 10,000 | Page 500,000
-------------+-----------+-----------+-------------+-------------
Offset       | 1.2ms     | 3.8ms     | 180ms       | 2,400ms
Cursor       | 1.2ms     | 1.3ms     | 1.3ms       | 1.3ms
Keyset       | 1.2ms     | 1.3ms     | 1.3ms       | 1.3ms

The difference is not marginal — at page 500,000, offset pagination is 1,800x slower than cursor pagination. This matters when you have background jobs iterating through entire tables or consumers that deep-paginate.

Common Mistakes

MistakeImpactFix
No pagination at allUnbounded response sizes, OOM crashesAlways paginate collection endpoints
No upper limit on limit parameter?limit=1000000 bypasses paginationCap at 100 (or your measured safe maximum)
Using OFFSET for background iterationProgressively slower as you go deeperUse cursor/keyset with WHERE clause
Cursor encodes only one fieldAmbiguous position when values are not uniqueEncode all sort key fields + a unique tiebreaker (ID)
No ORDER BY in paginated queryResults are non-deterministicAlways specify explicit, stable ordering
Offset with ORDER BY RANDOM()Different random order on every pageUse setseed() or pre-computed random column

GraphQL Pagination (Relay Connection Spec)

GraphQL's Relay specification defines a standard cursor-based pagination format:

graphql
query {
  orders(first: 20, after: "cursor_abc") {
    edges {
      cursor
      node {
        id
        status
        total { amount currency }
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    totalCount
  }
}

The edges / node / pageInfo structure is verbose but provides a consistent interface across all paginated queries in a GraphQL API.

Further Reading

"What I cannot create, I do not understand." — Richard Feynman