How to test API pagination
Ask a list endpoint for a small page and read the response headers: curl -s 'https://api.example.com/items?per_page=2' -D - -o /dev/null | grep -i link. A paginated API answers with link: and rel="next". Follow that link to the last page, then confirm every row appeared once and none went missing.
Why check this
Pagination is usually tested on one page and signed off. The failure surfaces later, in an export or a sync job that walks every page while the table is being written to, and the result is a row delivered twice or a row that never arrives. Run this in regression on every list endpoint, and again after any change to the default sort order or to the page size cap.
Prerequisites
- curl. No HTTP/2 build is needed. See the curl manual.
- The
Linkheader definition in RFC 8288, which is whatrel="next"comes from. - Node 22 and this file, saved as
pager.js. It answers both offset and cursor paging over the same ten rows, and accepts a POST that inserts a new newest row.
// pager.js - a list endpoint that answers both offset and cursor paging.
const http = require('node:http');
let last = 10;
const rows = Array.from({ length: 10 }, (_, i) => ({ id: 10 - i })); // newest first
http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:8099');
if (req.method === 'POST') {
last += 1;
rows.unshift({ id: last });
res.writeHead(201, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ inserted: last }));
}
const limit = Math.min(Number(url.searchParams.get('limit') || 3), 100);
const before = url.searchParams.get('before');
const start = before ? rows.findIndex((r) => r.id === Number(before)) + 1 : Number(url.searchParams.get('offset') || 0);
const page = rows.slice(start, start + limit);
const tail = page.length === limit ? page[page.length - 1].id : null;
res.writeHead(200, {
'content-type': 'application/json',
...(tail ? { link: `</items?limit=${limit}&before=${tail}>; rel="next"` } : {}),
});
res.end(JSON.stringify({ items: page.map((r) => r.id) }));
}).listen(8099, '127.0.0.1', () => console.log('pager on http://127.0.0.1:8099'));
Steps
- Step 1.
Ask a real paginated API for two rows and keep the headers.
curl -s 'https://api.github.com/repos/curl/curl/issues?per_page=2' -D - -o /dev/null | grep -i -E '^HTTP|^link|x-ratelimit-remaining'HTTP/2 200 link: <https://api.github.com/repositories/569041/issues?per_page=2&after=Y3Vyc29yOnYyOpLPAAABoJGxh3DPAAAAAUODSjw%3D&page=2>; rel="next" access-control-expose-headers: ETag, Link, Location, … x-ratelimit-remaining: 33The next page is identified by an opaque
aftercursor, not by a row number. Page one carries norel="prev". - Step 2.
Follow the
rel="next"URL exactly as it was given, without rebuilding it.curl -s 'https://api.github.com/repositories/569041/issues?per_page=2&after=Y3Vyc29yOnYyOpLPAAABoJGxh3DPAAAAAUODSjw%3D&page=2' -D - -o /dev/null | grep -i -E '^HTTP|^link|x-ratelimit-remaining'HTTP/2 200 link: <https://api.github.com/repositories/569041/issues?per_page=2&after=Y3Vyc29yOnYyOpLPAAABoJDQzDDPAAAAAUNftaY%3D&page=3>; rel="next", <https://api.github.com/repositories/569041/issues?per_page=2&page=1&before=Y3Vyc29yOnYyOpLPAAABoJDlG2jPAAAAAUNjI8s%3D>; rel="prev" access-control-expose-headers: ETag, Link, Location, … x-ratelimit-remaining: 32Page two carries both directions. The
beforecursor for the backward link differs from theaftercursor of page one, so a client that reverses a URL by hand lands somewhere else. - Step 3.
Ask for more rows than the server allows and count what arrives.
curl -s 'https://api.github.com/repos/curl/curl/commits?per_page=500' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).length))"100No error, no warning. The request was clamped and answered as if it were valid.
- Step 4.
Start the local list endpoint in a second terminal.
node pager.jspager on http://127.0.0.1:8099 - Step 5.
Take the first page by offset and note the ids and the cursor.
curl -s -D - 'http://127.0.0.1:8099/items?limit=3&offset=0'HTTP/1.1 200 OK link: </items?limit=3&before=8>; rel="next" {"items":[10,9,8]} - Step 6.
Insert a row, the way a live system does while your walk is in progress.
curl -s -X POST 'http://127.0.0.1:8099/items'{"inserted":11} - Step 7.
Take the second page by offset and compare it with step 5.
curl -s 'http://127.0.0.1:8099/items?limit=3&offset=3'{"items":[8,7,6]}Row 8 arrives twice. The insert pushed every row one place further down, so offset 3 now points at a row that offset 2 already returned. A delete moves rows the other way and the walk skips one instead.
- Step 8.
Take the second page again, this time with the cursor the server handed you in step 5.
curl -s 'http://127.0.0.1:8099/items?limit=3&before=8'{"items":[7,6,5]}Same data, same insert, no duplicate. The cursor names a row, and a row does not move when its neighbours change.
- Step 9.
Stop the endpoint with Ctrl+C in the terminal where it runs, so port 8099 is free for the next test.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| link: <…>; rel="next" | The server names the next page | Follow the URL as given. A client that builds its own drops cursor parameters it does not know. |
| No link header, a next field in the body | The cursor travels in the payload | Read it from the body. The walk is the same, the stop condition is the same. |
| per_page=500 answered with 100 rows | The page size is capped and the cap is silent | Stop when a page has no rel="next", never when a page is shorter than requested. |
| Page two repeats a row from page one | Offset paging over a table that gained a row | Page by an immutable sort key, or move the endpoint to cursors. |
| Page two skips a row | A row was removed or reordered mid-walk | Same fix. An offset cannot hold a position in a list that moves. |
| The last page returns rel="next" again | The stop condition is broken | Walk until the header is absent, and cap the loop so a broken server cannot spin it forever. |
Common mistakes
Thresholds
is the largest page api.github.com returns, whatever per_page asks for. Treat any cap as unknown until the test measures it.
What to check next
- How to check rate limit headers: a full page walk is the fastest way to spend a quota, and the headers say how much is left.
- How to test API concurrency: the writes that break an offset walk are the same writes this check sends in parallel.
- How to check HTTP response headers with curl: the
linkheader is read the same way as every other one. - How to check if API returns valid JSON: a page that arrives truncated fails parsing before pagination is ever reached.
- Api testing checklist: where this check sits in a release pass.
FAQ
How does pagination work in an API?
The server answers a slice of the list and says how to ask for the next one. Offset paging counts rows from the start. Cursor paging names the last row you saw. The client repeats until the server stops offering a next link.
How to test pagination in an API when the data keeps changing?
Write during the walk on purpose, as step 6 does. A pagination test on a frozen fixture passes on both designs and tells you nothing about the one that breaks in production.
How to test pagination in a UI?
Open DevTools, Network tab, click through the page controls and read the request for each click. Compare the parameters the UI sends with the cursor the API returned. A UI that increments a page number against a cursor API is the common defect.
Which page size should the test use?
The smallest one the endpoint accepts, usually 1 or 2. Small pages force many boundaries into a short test, and boundaries are where duplicates and gaps appear. Test the cap separately, as step 3 does.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny