← Recipes

阅读中文版本

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.

~30 mintestingpaginationapi

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.