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.
offset = 0 for the first page, but some APIs use offset = 1. A client using offset=0 may skip the first item.offset increases, the database must scan through all previous rows. For offset=10000, a query like SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 10000 performs a full sequential scan of the first 10,000 rows, degrading performance linearly with offset size.> 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.
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.
cursor=12345:2023-09-15T14:23:00Z encodes both id and created_at values.base64, url-encoding) to serialize composite cursors.created_at, use TIMESTAMP or TIMESTAMP WITH TIME ZONE to avoid drift.> 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.
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.
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.
cursor to reflect only the current filter, but the cursor value is derived from the full dataset. A new user added in a different city might appear in a paginated response for the San Francisco users.> 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.
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.
cursor as the primary navigation key, not offset. Clients should prefer cursor over offset unless the dataset is small and stable.total count, even if it is approximate. Clients can show “Showing 1–20 of 1234” and allow users to jump to a specific page.next_cursor and prev_cursor explicitly, even when the cursor is embedded in the links object.links.self to represent the current state, enabling clients to cache, bookmark, or share the current pagination state.Pagination fails silently if not properly instrumented. The following errors are common:
12345:2023-09-15T14:23:00Z, but the server expects 12345_2023-09-15T14:23:00Z. The cursor is not recognized, and the next page returns an empty result set.cursor=99999 but no user has id > 99999. The response returns an empty data array and next_cursor set to the first available cursor.limit=10 and cursor=99999, but the dataset is empty. The response is:{
"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.
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.
total count, even if approximate.cursor as the primary navigation parameter, not offset.base64, url-encoding, or a custom delimiter.links object for discoverability and hypermedia.limit, cursor, offset)400 Bad Request with invalid_cursor when cursor is malformed)200 OK for successful pagination400 Bad Request for invalid pagination parameters404 Not Found when a cursor does not existPagination 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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy