Quick answer: Reliable JSON API pagination needs a deterministic order, a pagination method that fits the dataset, opaque validated cursors, server-provided navigation links, enforced page limits, an explicit end signal, filter-aware tokens, and a documented consistency model. Missing any one of those contracts can create duplicates, gaps, endless loops, or misleading exports.
Pagination is not just LIMIT plus a JSON envelope. It is a protocol shared by the database query, API response, and client loop. These eight mistakes target the places where that protocol usually breaks, with examples you can test against a changing collection rather than only a static fixture.
If you are defining the surrounding response envelope too, start with the broader guide to JSON structure and security in REST APIs, then apply this pagination checklist to every collection endpoint.
Explore a complete response in the JSON Viewer, then use the JSONPath Query Tester to select IDs or pagination fields from your sample.
1. Does every page use a deterministic, unique order?
A paginated query needs a stable ordering whose final field uniquely breaks ties. Sorting only by a repeated timestamp, status, or name lets equal values change order between requests, causing overlaps or gaps. Add a unique field such as id as the last sort key and apply the identical ordering to every page.
Consider an events endpoint ordered only by createdAt. Two events created in the same millisecond can swap positions when the database chooses a different execution plan. If page one ends between those tied rows, page two has no reliable boundary.
Use a compound order instead:
ORDER BY created_at DESC, id DESC
The matching first page might be represented as:
{
"data": [
{ "id": 504, "createdAt": "2026-10-12T01:15:00.000Z" },
{ "id": 501, "createdAt": "2026-10-12T01:15:00.000Z" }
],
"page": { "nextCursor": "opaque-token" }
}
The next query must compare both boundary fields in the same direction. For descending order, fetch rows where created_at is earlier than the boundary, or where it is equal and id is smaller. Index the ordered columns in a way that supports the real query and filters; otherwise the contract may be correct while deep traversal remains slow.
The Django REST Framework pagination documentation similarly recommends an unchanging, non-null, indexed ordering field and explains why imprecise or non-unique values can produce inconsistent results. That is framework guidance, but the database principle applies more broadly.
2. Are you using offsets on a collection that changes during traversal?
Offset pagination addresses numbered positions, not durable records. When an item is inserted or deleted ahead of the client's current offset, remaining positions shift and the next request can repeat or skip data. Use a cursor or keyset boundary for changing feeds and exports, or explicitly accept offset consistency limits for stable, small datasets.
Suppose a client requests ?offset=0&limit=2 and receives IDs 10 and 9. Before it requests offset 2, a new ID 11 is inserted at the front. The current order is now 11, 10, 9, 8. Offset 2 returns IDs 9 and 8, so ID 9 appears twice.
Deletion produces the opposite failure. If ID 10 disappears before the second request, the array becomes 9, 8, 7. Offset 2 begins at ID 7, and the client never sees ID 8.
The JSON:API cursor pagination profile documents these insert and delete anomalies and defines page[after], page[before], and page[size]. JSON:API itself remains strategy-neutral; its pagination specification reserves the page parameter family and standardises collection navigation links.
Offset is still reasonable for an admin table with a small, mostly static dataset and a genuine “jump to page 17” requirement. State that trade-off honestly. Do not describe offset pagination as snapshot-consistent unless the backing system actually holds a snapshot across requests.
3. Can clients treat your cursor as opaque and unforgeable enough?
Clients should store and return a cursor without parsing, incrementing, or constructing it. The server should validate its syntax, version, scope, and integrity before using it in a query. A Base64 string is only an encoding; sign or otherwise protect tokens when tampering could expose unauthorized ranges or corrupt expensive database work.
A cursor may internally contain a boundary such as:
{
"v": 1,
"createdAt": "2026-10-12T01:15:00.000Z",
"id": 501,
"sort": "createdAt:desc"
}
Do not expose that object as an invitation for clients to edit it. Serialize it deterministically, protect it according to the threat model, and return one token. On receipt, reject an unsupported version, malformed value, invalid signature, wrong endpoint, or expired token with a documented client error. Never splice decoded cursor text into SQL; use parameterized queries.
Opacity also preserves freedom to change indexes or add a tie-breaker. If clients assume a cursor is “just the last ID,” a later switch to timestamp-plus-ID becomes a breaking change even when the public parameter name stays the same.
For background, Base64 converts binary data into text; it does not provide confidentiality or authenticity. The authorization checks for every returned record still apply regardless of how a cursor is encoded.
4. Do responses provide complete navigation links instead of cursor fragments?
Return a complete next URL, or an equally precise documented token contract, so clients do not rebuild requests from assumptions. Navigation must preserve filters, sort order, page size, API version, and encoding. A server-generated link reduces client bugs and gives the service room to change pagination parameters without rewriting every integration.
A useful response shape is:
{
"data": [
{ "id": 504, "status": "open" },
{ "id": 501, "status": "open" }
],
"links": {
"self": "/v1/events?status=open&page[size]=2",
"next": "/v1/events?status=open&page[size]=2&page[after]=opaque-token",
"prev": null
}
}
JSON:API assigns first, last, prev, and next names to pagination links and allows unavailable links to be absent or null. An API does not have to implement JSON:API to borrow the central design lesson: navigation is part of the response contract.
Some APIs use the HTTP Link header instead of JSON body fields. GitHub's current REST pagination guide tells clients to follow the URLs associated with relations such as next, because query parameters differ across endpoints and some relations cannot always be calculated. Pick one documented mechanism, test it through proxies, and keep its URLs safe to log.
5. Can callers request an unbounded or misleading page size?
Enforce a positive default and a documented maximum page size on the server. Reject or clearly clamp invalid values, then report the effective size when ambiguity matters. Without limits, one request can consume excessive database, serialization, memory, and network resources; silent clamping can also make a client mistake a partial page for completion.
Validate page[size] before it reaches query construction. Decide how the endpoint handles zero, negative numbers, non-integers, repeated parameters, and values above the maximum. The JSON:API cursor profile requires a positive integer and defines errors for invalid or excessive sizes; other APIs may choose clamping, but clients need to know which behavior applies.
Do not equate the requested limit with the number returned. Authorization filters, post-query business rules, time budgets, or provider caps can produce a shorter page while more records remain. GitHub notes that most REST endpoints cap per_page at 100 and may reduce a larger value without an error. That is exactly why a generic client must follow the navigation signal instead of guessing from array length.
Include page-size tests for the boundaries:
| Request | Expected behavior |
|---|---|
| size omitted | Apply documented default |
size 1 | Return at most one item plus navigation |
size 0 or -1 | Return a documented validation error |
| size above maximum | Reject or clamp as documented |
| size is text | Return a documented validation error |
6. Does the client stop only on the documented end signal?
A client should stop when the API's explicit navigation signal says no next page, not merely when data.length is below the requested size. It should also reject a repeated cursor or URL, cap work when appropriate, and surface malformed responses. Those guards prevent silent truncation and infinite loops during exports.
This loop follows a body-level links.next contract:
async function fetchAll(firstUrl) {
const rows = [];
const seen = new Set();
let url = firstUrl;
while (url !== null) {
if (seen.has(url)) throw new Error("Pagination loop: repeated next URL");
seen.add(url);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const page = await response.json();
if (!Array.isArray(page.data)) throw new Error("Missing data array");
rows.push(...page.data);
url = page.links?.next ?? null;
}
return rows;
}
The code deliberately does not calculate the next cursor, increment a page number, or stop on a short page. Production clients also need retry, timeout, rate-limit, and cancellation behavior. If a page request is retried, collecting records by stable ID can protect the local result from duplication, but deduplication should not conceal a broken server contract.
Our guide to fetching JSON in JavaScript covers status checks, content types, timeouts, and body parsing. Use JSON Prettifier to inspect a non-sensitive raw page while debugging its response shape.
7. Are sort and filter changes bound to the pagination state?
A cursor created for one query must not silently resume a different query. Bind or validate the endpoint, tenant, filters, sort direction, and relevant authorization context when processing the token. If the client changes any of those inputs, restart pagination or return a clear error instead of producing a plausible but incomplete result.
Imagine page one uses status=open&sort=-createdAt, then the client sends its cursor with status=closed. A token containing only the last ID cannot prove which ordered set it describes. The second page may be empty, overlap unrelated data, or cross a tenant boundary if authorization is also mishandled.
There are two defensible designs:
- Encode protected query state in the cursor and ignore duplicated client parameters.
- Accept the parameters again, include their canonical representation or digest in the protected cursor, and reject mismatches.
Canonicalization matters when filters can be written in equivalent orders. Sort keys, repeated parameters, Unicode, absent values, and defaults all need one defined representation before hashing or signing. Our JSON canonicalization checks explain why semantically similar data does not automatically produce identical bytes.
Keep authorization outside the cursor as an active server check. A correctly signed token proves that the service issued certain state; it does not prove that the current caller is still allowed to access every matching record.
8. Have you defined what concurrent writes mean for a complete export?
Cursor pagination prevents position shifting, but it does not automatically freeze the dataset. New rows, updates to sort fields, and deletions can still affect what a long traversal observes. Define whether the endpoint offers a live view, a high-water-mark export, or a true snapshot, then test inserts and updates at page boundaries.
For an activity feed, a live view is usually acceptable: new records appear before the starting boundary and are intentionally absent from the current forward traversal. The client refreshes from the top later. For billing, compliance, or backup exports, that may be unacceptable because “all records” needs a reproducible cut-off.
A high-water-mark design can capture an upper boundary on the first request and carry it through every cursor:
{
"data": [],
"page": {
"nextCursor": "opaque-token",
"snapshotThrough": "2026-10-12T02:00:00.000Z"
}
}
The query then restricts every page to records at or before that boundary. This works only if the chosen field and update rules match the export's meaning. A record created earlier but backfilled later, or an existing record whose ordered field changes, may still require database snapshot semantics, change-data capture, or a dedicated asynchronous export job.
Test the contract with more than a static array. Insert a row before the current boundary, delete the last item from the previous page, update a tie value, retry the same cursor, and traverse an empty collection. Assert both completeness and uniqueness using record IDs. The goal is not to promise that data never changes; it is to make the observed consistency predictable.
These eight checks form one pagination review: order uniquely, choose the right addressing model, protect cursors, return navigation, limit pages, stop explicitly, bind query state, and define concurrent-write behavior. If the server and client can state each rule in a test, pagination stops being an optimistic loop and becomes a verifiable API contract.
When collection reads are packaged together, run the Microsoft Graph JSON batch tests to verify that a completed envelope does not hide continuation pages or partially failed operations.