Api Pagination Strategies

Production-grade guide to api pagination strategies covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

API pagination is essential when a resource returns more data than can fit in a single HTTP response. It matters when clients must fetch large datasets—such as user lists, product catalogs, or log entries—without overwhelming network bandwidth, memory, or client-side processing. Pagination enables efficient, incremental retrieval of data, allowing clients to navigate through large result sets with predictable performance and minimal latency.

Offset pagination, also known as *limit-offset* or *cursor-based* pagination with fixed offsets, is the simplest way to paginate through a dataset. It relies on two parameters: limit to control the number of items per page and offset to indicate where to start retrieving data.

GET /api/users?limit=20&offset=40 HTTP/1.1
Host: api.example.com
Accept: application/json

The server responds with a list of 20 users starting from the 41st user (index 40), along with metadata:

{
  "data": [
    { "id": 41, "name": "Alice", "email": "[email protected]" },
    { "id": 42, "name": "Bob", "email": "[email protected]" },
    ...
  ],
  "pagination": {
    "limit": 20,
    "offset": 40,
    "total": 1234,
    "pages": 62,
    "next_offset": 60,
    "prev_offset": 20
  }
}

The client uses next_offset to fetch the next page and prev_offset for backward navigation. This approach works well for small to medium datasets and when the underlying database supports efficient OFFSET and LIMIT clauses.

Common Pitfalls and Gotchas

> Failure mode: A client paginates through 100 pages of logs, but the database index rebuilds during the process. The same log entry appears in two consecutive pages, and a new log entry is skipped entirely.

Replacing Offset with Cursors: Keyset Pagination

To overcome the limitations of offset pagination, use *keyset pagination*, where each page uses a *cursor*—a unique value from the last item in the previous page—to start the next fetch.

The cursor is typically the value of a stable, unique, and ordered field, such as id, created_at, or a composite of multiple fields.

GET /api/users?cursor=12345&limit=20 HTTP/1.1
Host: api.example.com
Accept: application/json

The server uses the cursor to define the WHERE condition:

SELECT * FROM users
WHERE id > $1
ORDER BY id
LIMIT $2

The cursor is the value of id from the last user in the previous page. The response includes the cursor for the next page:

{
  "data": [
    { "id": 12345, "name": "Charlie", "email": "[email protected]" },
    { "id": 12346, "name": "Dana", "email": "[email protected]" },
    ...
  ],
  "pagination": {
    "limit": 20,
    "cursor": "12346",
    "total": 1234,
    "next_cursor": "12365"
  }
}

The client uses next_cursor to fetch the next set of results.

Advanced Cursor Patterns

> Silent failure: A client uses updated_at as a cursor, but a background job updates existing users. A user appears in two consecutive pages, and the cursor value is no longer unique.

Implementing Cursor-Based Pagination with Multiple Sort Criteria

When results must be sorted by multiple fields, cursor-based pagination becomes more powerful.

GET /api/orders?cursor=12345:2023-09-15T14:23:00Z&limit=25 HTTP/1.1
Host: api.example.com
Accept: application/json

The server uses the cursor to filter and sort:

SELECT * FROM orders
WHERE (id, created_at) > ($1, $2)
ORDER BY id, created_at
LIMIT $3

The response includes the next cursor:

{
  "data": [
    {
      "id": 12345,
      "created_at": "2023-09-15T14:23:00Z",
      "status": "shipped"
    },
    ...
  ],
  "pagination": {
    "limit": 25,
    "cursor": "12367:2023-09-16T09:12:00Z",
    "next_cursor": "12367:2023-09-16T09:12:00Z"
  }
}

Clients can now navigate efficiently through large, multi-sorted datasets with stable, consistent results.

Handling Pagination in the Presence of Filters

Filtering complicates pagination. When a client applies filters, the cursor must encode the filter state.

For example, a request to fetch users in a specific city, filtered by status:

GET /api/users?city=San+Francisco&status=active&limit=20&cursor=12345 HTTP/1.1

The server applies filters and uses the cursor to paginate:

SELECT * FROM users
WHERE city = 'San Francisco'
  AND status = 'active'
  AND id > $1
ORDER BY id
LIMIT $2

The cursor value is the id of the last user in the result set.

Challenges with Filtered Pagination

> Failure mode: A client paginates through active users in San Francisco, but during the process, a user is deactivated. That user appears in the first page but is not present in the second, even though the cursor is based on id alone.

Building a Robust Pagination Response Format

To enable clients to navigate, cache, and retry effectively, design a consistent pagination response structure.

{
  "data": [
    { "id": 12345, "name": "Eve", "email": "[email protected]" },
    { "id": 12346, "name": "Frank", "email": "[email protected]" }
  ],
  "links": {
    "self": "/api/users?limit=20&cursor=12345",
    "first": "/api/users?limit=20",
    "last": "/api/users?limit=20&cursor=98765",
    "next": "/api/users?limit=20&cursor=12346",
    "prev": "/api/users?limit=20&cursor=12344"
  },
  "meta": {
    "limit": 20,
    "total": 1234,
    "pages": 62,
    "cursor": "12346",
    "next_cursor": "12365",
    "prev_cursor": "12344"
  }
}

Clients can use the links object to discover navigation paths and build hypermedia-driven clients.

Key Design Decisions

Error Handling in Pagination

Pagination fails silently if not properly instrumented. The following errors are common:

{
  "data": [],
  "pagination": {
    "limit": 10,
    "cursor": "99999",
    "total": 0,
    "next_cursor": "100000"
  }
}

> Failure mode: A client tries to paginate through a user list but receives empty pages for all requests. The next_cursor is set correctly, but the client never discovers that no more data is available.

Choosing the Right Strategy

Use offset pagination when:

Use cursor pagination when:

> Edge case: A client implements a “Load More” button with cursor pagination but forgets to include next_cursor in the response. The button becomes inactive after the first click.

Best Practices Summary

Pagination is not just a utility—it is a contract between server and client. When done well, it enables clients to handle large datasets with low latency, high reliability, and rich navigation.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.