Test Pagination at Its Boundaries
Cover empty results, exact page size, last page, invalid cursors, and tied sort keys so clients never loop or skip records.
Task
Verify pagination behaviour at the edges, where off-by-one and cursor bugs hide.
When to use this
A cursor endpoint returns the same last row on consecutive pages when timestamps tie, so a client syncing data loops forever or silently drops records.
Prerequisites
- Node.js 22 with a paginated endpoint
- A deterministic seed helper that can create exact row counts
- A cursor-based implementation ordered by (created_at, id)
Steps
1. Write the boundary cases as a table with expected page counts.
const cases = [
{ name: 'empty', rows: 0, limit: 10, pages: 0 },
{ name: 'exact page', rows: 10, limit: 10, pages: 1 },
{ name: 'one over', rows: 11, limit: 10, pages: 2 },
];
Expected output: Each case names the boundary and asserts page count, not just the status code of the first page.
2. Seed rows that share a sort timestamp so ties are exercised.
await seedEvents([
{ id: 1, createdAt: '2026-10-01T00:00:00Z' },
{ id: 2, createdAt: '2026-10-01T00:00:00Z' },
{ id: 3, createdAt: '2026-10-01T00:00:00Z' },
{ id: 4, createdAt: '2026-10-02T00:00:00Z' },
]);
Expected output: Three rows tie on created_at; ordering by created_at alone would repeat or skip a row at the page edge.
3. Walk every page, assert no duplicates, and require the final cursor to be null.
const seen = new Set();
let cursor = null;
let pages = 0;
do {
const qs = cursor ? `?limit=4&cursor=${encodeURIComponent(cursor)}` : '?limit=4';
const res = await request(`/api/events${qs}`);
for (const row of res.body.data) {
assert.ok(!seen.has(row.id), `duplicate ${row.id}`);
seen.add(row.id);
}
cursor = res.body.nextCursor;
pages += 1;
} while (cursor && pages < 10);
assert.equal(seen.size, 11);
assert.equal(cursor, null);
Expected output: All 11 ids appear exactly once across three pages, and the last response has nextCursor null.
4. Reject a malformed cursor with a stable error code instead of an empty page.
const res = await request('/api/events?cursor=not-a-cursor');
assert.equal(res.status, 400);
assert.equal(res.body.error, 'invalid_cursor');
Expected output: A tampered or truncated cursor returns 400 with error=invalid_cursor, not 200 with zero rows.
5. Pin the clamping rules for zero, huge, and negative limits.
assert.equal((await request('/api/events?limit=0')).body.meta.limit, 1);
assert.equal((await request('/api/events?limit=1000')).body.meta.limit, 100);
assert.equal((await request('/api/events?limit=-5')).status, 400);
Expected output: Zero clamps to 1, one thousand clamps to 100, and a negative limit is rejected with 400.
Troubleshooting
Problem
Rows are duplicated at page boundaries
Solution
The sort key is not unique. Order by (created_at, id) and encode both values in the cursor so ties have a stable position.
Problem
The last page still returns a next cursor
Solution
Fetch limit + 1 rows to detect whether more exist, and return null when only the requested page was found.
Problem
Cursors break after rows are deleted
Solution
Keyset pagination survives deletes; offset pagination shifts rows. Use keyset for anything that clients sync.
Problem
Old cursors become invalid after a deploy
Solution
Version the cursor payload and accept the previous format during the rollout window.
Limitations
- Keyset pagination cannot jump to page N; that is the deliberate trade-off for stable boundaries.
- Cursors can expose filters or ids unless signed; sign them when they encode sensitive parameters.
- Tests assume a stable dataset; concurrent writes can change counts between pages.
Most pagination tests check the happy path
One page, a valid limit, rows in order: that test passes for almost every implementation, including ones that duplicate a row at a tie or claim there is a next page on the last page. The bugs live at the boundaries. An empty collection, a collection that is exactly one page, a collection with one extra row, and a cursor that no longer exists cover the arithmetic that clients depend on.
Make ties explicit
ORDER BY created_at is not deterministic when several rows share a timestamp. The database may return them in different orders between queries, so keyset pagination can repeat a row or skip one. The fix is a composite sort key (created_at, id) with both values encoded in the cursor. Seed test data with ties, otherwise the bug never appears in CI and only shows up for a customer with batch-imported data.
if (cursor) assert.ok(!seen.has(row.id), `duplicate ${row.id}`);
Detect the last page correctly
The common off-by-one is returning a nextCursor on the final page because the implementation assumes “returned a full page” means “there is more”. Fetch limit + 1 rows, return limit, and set nextCursor only if the extra row exists. The test should walk all pages and assert that the final cursor is null; checking only the first page misses this entirely.
Validate cursors as input
A cursor is user input. Tampered or truncated base64 should produce a 400 with a stable error code, not a 500 or a silent empty page. If the cursor is signed, verify the signature before decoding; if it is opaque, validate the decoded shape. Treating an invalid cursor as “start from the beginning” hides client bugs and can create infinite loops.
Pin the limit rules
Clients send limit=0, limit=100000, or limit=-1. Decide whether each is clamped or rejected, document it, and test it. A negative limit that reaches the SQL layer can produce a database error, and an unbounded limit can turn a list endpoint into an export. Clamping at the edge is the simplest policy, as long as it is consistent and visible in the response metadata.