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

// 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

  1. 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: 33

    The next page is identified by an opaque after cursor, not by a row number. Page one carries no rel="prev".

  2. 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: 32

    Page two carries both directions. The before cursor for the backward link differs from the after cursor of page one, so a client that reverses a URL by hand lands somewhere else.

  3. 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))"
    
    100

    No error, no warning. The request was clamped and answered as if it were valid.

  4. Step 4.

    Start the local list endpoint in a second terminal.

    node pager.js
    
    pager on http://127.0.0.1:8099
  5. 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]}
  6. 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}
  7. 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.

  8. 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.

  9. 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

Sign: The export finishes with fewer rows than the table holds, and the count differs on every run.Cause: The walk uses limit and offset while rows are being inserted or deleted. Each write shifts the window, so a row slides past the boundary between two requests. The test only shows it when writes happen during the walk, which is why step 6 inserts one.
Sign: per_page=500 returns 100 rows and the client reports the job as complete.Cause: The server clamps the page size and answers 200 with no notice. A client that stops as soon as a page is shorter than requested treats the clamp as the end of the list and silently drops everything after the first page.
Sign: Paging works against the staging fixture and loops forever in production.Cause: The client rebuilds the next URL from a page number instead of following rel=next. With cursor paging the page number carries no position, so the server keeps answering the same window and the loop never reaches the end.
Sign: Sorting by created_at gives a stable first page and a scrambled second one.Cause: The sort key is not unique. Rows sharing a timestamp have no defined order, so the database is free to return them differently on each query. A tiebreaker on the primary key is what makes the order repeatable.

Thresholds

100 rows

is the largest page api.github.com returns, whatever per_page asks for. Treat any cap as unknown until the test measures it.

Source: measured on api.github.com in step 3, 2026-09-11

What to check next

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.

intermediate9 minpublished updated Maks Verny