APIs
Pagination: SQL and code reading
Pagination bugs live in the query plan and the cursor handler, not in the spec. Read each snippet, predict what happens at depth and under concurrent writes, then choose the fix a senior engineer makes first.
Practise the loop you run on every slow or flaky list endpoint: read the SQL and the plan, predict where the cost or the drift comes from, and reach for the change that fixes the root cause.
Snippet 1 — the deep-offset plan
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;
-- Limit (rows=20) (actual rows=20 loops=1)
-- -> Index Scan ... on articles (actual rows=200020 loops=1)
The plan uses the index yet still takes seconds. What does 'actual rows=200020' on the Index Scan tell you, and what is the fix?
Snippet 2 — the cursor handler
// GET /articles?after=<cursor>&limit=20
async function listArticles({ after, limit = 20 }) {
const { createdAt, id } = decodeCursor(after); // Base64 JSON -> {createdAt, id}
const rows = await db.query(`
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
`, [createdAt, id, limit]);
const last = rows[rows.length - 1];
return {
items: rows,
nextCursor: encodeCursor({ createdAt: last.created_at, id: last.id }),
hasNextPage: true,
};
}
The seek and ordering are correct, but hasNextPage is wrong. What is the bug and the standard fix?
Snippet 3 — the ORDER BY mismatch
CREATE INDEX idx_articles_feed ON articles (created_at DESC, id DESC);
-- handler issues:
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-05-01 09:00:00', 918273)
ORDER BY created_at ASC, id ASC -- note: ASC
LIMIT 20;
The composite index exists and the tuple comparison looks right, but the feed shows the wrong rows and latency is not flat. What is wrong?
Snippet 4 — the count footer
const [{ total }] = await db.query(`SELECT COUNT(*) AS total FROM articles`);
const items = await listArticles({ after, limit: 20 });
return { items, total }; // renders "Showing 1–20 of {total}"
The keyset page is ~10 ms but the request p99 is seconds. The COUNT(*) is the culprit. What is the highest-leverage change?
Pagination is diagnosed in the plan and the handler: ‘actual rows’ far above the page size is the offset scan-and-discard signature, and keyset replaces the skip with a tuple seek; a cursor handler must fetch n+1 to derive hasNextPage rather than hardcode it; the ORDER BY direction, the comparison operator, and the composite index must all agree or you lose both correctness and flat latency; and an exact COUNT(*) per request is an MVCC full scan that dwarfs a fast page — estimate it or drop it. Read the plan, fix the root cause, then re-check the plan.