Reach for this skill when an endpoint returns a list that's too big for one response and must page through it correctly:
-
Default to keyset (cursor) pagination; reach for OFFSET only for small, static, jump-to-page-N admin tables. The two models:
| Offset/limit | Keyset/cursor |
|---|
| Query | ORDER BY ... LIMIT 20 OFFSET 980 | WHERE (sort_key,id) < (?,?) ORDER BY ... LIMIT 20 |
| Deep-page cost | O(offset) — DB scans + discards all skipped rows | O(1) w/ index — seeks straight to the cursor |
| Concurrent insert/delete | skips or duplicates rows (offset shifts under you) | stable — anchored to a value, not a position |
| Jump to page N | yes | no (sequential only) |
| Total pages | derivable (needs COUNT) | not directly |
Offset is fine for a 200-row config table behind admin; for any feed, search, timeline, or table that grows or is written concurrently, keyset is the default.
-
Pick a stable total ordering with a unique tie-break — this is the whole game. The ORDER BY columns must be (a) the user-visible sort and (b) made total by appending a unique, immutable column (the PK) so no two rows compare equal. A non-unique sort (ORDER BY created_at alone) lets rows with the same timestamp straddle a page boundary → duplicates or skips.
ORDER BY created_at DESC, id DESC
The cursor encodes the full sort tuple of the last row returned: (created_at, id). Use row-value comparison so it's one index seek:
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size + 1;
For ASC use >. For mixed directions (created_at DESC, name ASC) row-value syntax doesn't apply — expand the boolean predicate explicitly:
WHERE created_at < :c
OR (created_at = :c AND name > :n)
OR (created_at = :c AND name = :n AND id > :id)
Tie-break key must be unique and never-updated (PK, not a mutable slug). If the sort column itself is mutable (e.g. updated_at), a row can move pages — acceptable for "recently updated" feeds, surprising for "newest"; document it.
-
Make cursors opaque and self-describing — base64url-encode a small payload, never expose raw offsets/ids. A cursor is a token the client echoes back verbatim; it is NOT a stable id and clients must not parse it. Encode the sort tuple plus enough to detect misuse:
{ "k": [1718409600000, 84213], "d": "desc", "f": "a1b2c3" }
base64url(JSON) → eyJrIjpb.... Rules:
- Opaque: document it as "treat as opaque; do not construct or parse." Lets you change the internal format later without breaking clients.
- Bind the query shape: include
f = a hash (or the canonical filter/sort) the cursor was created under. On the next request, reject (400) if the client changed filter/sort but reused the cursor — a cursor is only valid against the exact query that produced it.
- Don't trust it for authz: re-apply tenant/visibility filters on every page; never assume the cursor proves access. Tamper-resistance optional — sign (HMAC) only if a forged cursor could leak data past a filter; usually re-filtering server-side is enough.
- Cross-page consistency: a cursor's sort tuple is independent of position, so inserts/deletes between pages don't shift it — the core win over offset.
-
Set a page-size default and a hard cap; fetch limit + 1 to compute has_more cheaply. Never let the client ask for unbounded rows (DoS / memory blowup).
| Param | Value |
|---|
default page_size | 20–50 |
| hard max | 100 (clamp, don't 400 — min(requested, 100)) |
limit + 1 trick | query page_size + 1; if you got the extra row, has_more = true, drop it from data, its predecessor's key is next_cursor |
This avoids a separate COUNT(*) just to know if there's a next page. Reject page_size <= 0.
-
Return the {data, next_cursor, has_more} envelope; make next_cursor null at the end. Stable, minimal contract:
{
"data": [ ],
"next_cursor": "eyJrIjpbMTcxODQw...",
"has_more": true
}
next_cursor is the cursor of the last returned row (after dropping the +1 probe). Client passes it back as ?cursor=....
- When
has_more=false, next_cursor=null and clients stop — don't make them request an empty page to discover the end.
- Omit total by default. A
total_count forces a full COUNT(*) (often as slow as the data scan) and is meaningless under concurrent writes. Offer it only as an opt-in (?include_total=true), cache it, or return an estimate (reltuples / approximate count).
-
Support backward paging when the UI needs "previous" — flip comparison + order, then re-reverse. For bidirectional cursors carry the direction in the token. To page backward from a cursor: flip <→> (and the ORDER BY direction), LIMIT n+1, then reverse the returned slice in memory so the client still gets ascending-by-display order. Track both has_next and has_prev. Relay's pageInfo (next step) formalizes this with hasNextPage/hasPreviousPage + startCursor/endCursor.
-
For GraphQL, follow the Relay Cursor Connections spec exactly — don't invent a connection shape. REST and GraphQL share the same keyset engine; only the envelope differs. Relay structure:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge { node: Post! cursor: String! }
type PageInfo {
hasNextPage: Boolean! hasPreviousPage: Boolean!
startCursor: String endCursor: String
}
first: 20, after: "<cursor>" is forward; last: 20, before: "<cursor>" is backward. Each edge.cursor is the opaque keyset token for that node.
pageInfo.endCursor ↔ REST next_cursor; hasNextPage ↔ has_more. totalCount is a separate, optional field — same COUNT cost caveat (step 5).
- Don't mix
first with last, or after with before, in one request — reject it.
-
Hand the ordering to optimize-sql-query and make sure a composite index covers it. Keyset is only O(1) if a B-tree index matches the ORDER BY columns in order and direction: CREATE INDEX ON posts (created_at DESC, id DESC) (plus any equality filter columns as a leading prefix: (tenant_id, created_at DESC, id DESC) for a per-tenant feed). Without it the DB sorts the whole table per page and you've gained nothing. Verify with EXPLAIN that it's an index range scan with no Sort node and Rows Removed by Filter ≈ 0.
Done = list endpoints use keyset cursors over a unique-tie-break total ordering, cursors are opaque base64url tokens bound to their query, page size is defaulted and hard-capped, has_more comes from limit+1 (no mandatory COUNT), the {data,next_cursor,has_more} (or Relay connection) envelope is stable, a composite index backs the ordering, and the consistency/perf tests in checks 1–9 pass under concurrent writes.