awesome-everything RU
↑ Back to the climb

APIs

Pagination: SQL and code reading

Crux Read real SQL, an EXPLAIN line, and a cursor handler, then predict the pagination behaviour and pick the highest-leverage fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min

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.

Goal

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)
Quiz

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,
  };
}
Quiz

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;
Quiz

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?

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}"
Quiz

The keyset page is ~10 ms but the request p99 is seconds. The COUNT(*) is the culprit. What is the highest-leverage change?

Recap

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.

Continue the climb ↑Pagination: migrate an offset feed to keyset
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.