Pagination That Survives a Large Table
Offset pagination is easy and gets quadratically more expensive with depth; keyset pagination is cheap at any depth and gives up random access to page N.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
How do I let a client walk a large result set without the last page costing more than the first?
An admin screen lists orders. There are tens of millions of them, some users page deep, and one integration walks the entire table nightly.
Accept page and pageSize, translate to LIMIT/OFFSET, and return a total count so the UI can render page numbers. It is four lines and everyone understands it.
Page 1 is instant and page 20,000 is slow, because the database must produce and discard every preceding row before returning the requested ones. The cost grows with the offset, so the deepest pages are the most expensive (Query Optimization: Finding the Actual Bottleneck in Database Engineering).
- Page 1 is instant and page 20,000 is slow, because the database must produce and discard every preceding row before returning the requested ones. The cost grows with the offset, so the deepest pages are the most expensive (Query Optimization: Finding the Actual Bottleneck in Database Engineering).
- The nightly integration walks every page, so it pays that growing cost on every request and its total work is quadratic in the number of rows.
- Rows are inserted while a user pages, so the result set shifts underneath them: items are skipped or shown twice, and neither is an error anyone can see.
SELECT count(*)on a large filtered table scans to count, so returning a total makes every page more expensive than the page itself (Sequential Scan, Page by Page in Database Engineering).- A client passes
pageSize=100000and the request serialises a vast response, exhausting memory and blocking the runtime (What Serialization Costs). - The sort key is not unique, so rows with equal values are ordered arbitrarily and can appear on two consecutive pages (Unbounded Collections: The Anti-Pattern With a Fuse in API Design).
What is actually happening
OFFSET ndoes not skip work. The database still produces the firstnrows in order and discards them, so the work per page is proportional to the offset and the work to walk the whole table is proportional to the square of its size.- An index helps the ordering but does not remove the discarding: the engine still walks the index entries it is skipping.
- Keyset (cursor) pagination replaces "skip n rows" with "start after this value".
WHERE (sort_key, id) > (last_key, last_id) ORDER BY sort_key, id LIMIT kis an index seek followed by a sequential read ofkentries — the same cost for page 1 and page 20,000. - The key must be unique and stable, which usually means a tuple: the sort column plus a tiebreaker primary key. Without the tiebreaker, rows with equal sort values are ordered arbitrarily and can be skipped or repeated.
- The index must match the ordering, including direction and the tiebreaker column, or the engine sorts instead of seeking and the advantage disappears (Composite Indexes and the Leftmost-Prefix Rule in Database Engineering).
- Keyset pagination is inherently relative: it can go to the next page and the previous one, and it cannot jump to page 137, because "page 137" is a count of skipped rows and that is precisely the operation being avoided.
- Stability differs too. Offset paginates over positions, which move when rows are inserted or deleted; keyset paginates over values, so a concurrent insert before your position simply does not appear.
- A total count is a separate query with its own cost. On a large filtered set it is often more expensive than every page combined, and it is usually not what the user needs.
Why the last page costs more than the first
created_at < $2 OR (created_at = $2 AND id < $3); verify with the engine's plan output rather than assuming (Reading EXPLAIN ANALYZE in Database Engineering).The mechanism is worth stating plainly because it is so often assumed away: OFFSET 200000 instructs the database to produce two hundred thousand rows in the correct order and throw them away. There is no data structure that makes that free, and an index does not change it — an index makes the ordering cheap, not the skipping.
Keyset pagination changes the operation. Instead of "skip 200,000 rows", it says "find the entry just after this value", which is a single index seek, followed by reading the next k entries in order. Page one and page twenty thousand cost the same, and that flatness is the whole point.
SELECT id, created_at, total FROM orders WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 20 OFFSET 400000; -- The engine must produce 400,020 rows in order -- and discard 400,000 of them. -- -- Cost grows with the offset. Walking the whole -- table page by page is quadratic in row count. -- -- Plus, for a page-number UI: -- SELECT count(*) FROM orders WHERE tenant_id = $1; -- which scans the whole filtered set, every page.
-- Cursor carries the last row's ordering tuple. SELECT id, created_at, total FROM orders WHERE tenant_id = $1 AND (created_at, id) < ($2, $3) -- DESC: strictly before ORDER BY created_at DESC, id DESC LIMIT 20; -- Index seek to the cursor position, then read 20 -- entries. Identical cost at any depth. CREATE INDEX ON orders (tenant_id, created_at DESC, id DESC); -- The index must match the ORDER BY exactly -- -- columns, order and direction -- or the engine -- sorts and the advantage disappears.
The two queries express different operations. Offset is "count rows and discard", which is inherently proportional to how deep you are; keyset is "seek to a value", which is a tree descent of the same cost regardless of position. The id tiebreaker is not decoration: without it, orders sharing a created_at have no defined relative order, and rows at page boundaries are skipped or duplicated on every request.
Choosing between them
The decision is driven by two questions: can this set grow without bound, and does anyone genuinely need to jump to an arbitrary page? Most endpoints answer "yes" and "no", which points at keyset. A settings list of twelve rows answers "no" and "irrelevant", which points at whichever is simpler to write.
The hybrid row is worth knowing: offset within a bounded window is how "jump to page N" is offered without paying deep-page cost — cap the offset, and require a cursor beyond it.
How large can the set get, and how do clients traverse it?
when Unbounded growth, feeds, logs, integrations walking the whole set.
cost No jump-to-page; needs a matching composite index and a unique tiebreaker (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design).
when Small, bounded, mostly static sets; admin screens over a few thousand rows.
cost Deep-page cost and drift under concurrent writes; cap the maximum offset regardless (Offset Pagination: Simple, Jumpable, and Lying Under Writes in API Design).
when A page-number UI is a hard requirement.
cost Jump-to-page within the first N pages only; a cursor beyond it.
when The UI wants a sense of scale but not an exact number.
cost The count is an estimate and must be labelled as one.
when Event and log data queried by period.
cost Client must supply a range; unbounded ranges are back to the original problem.
when A consumer needs the entire data set regularly.
cost A different interface entirely, and usually the right one (Background Jobs).
Implementing the cursor
A cursor is the ordering tuple of the last row returned, encoded so that clients treat it as opaque. Opacity matters for two reasons: it keeps the internal ordering key changeable without breaking clients, and it discourages the construction of arbitrary cursors by hand.
Opaque is not the same as trusted. The cursor is client-supplied input and must be validated; anything it encodes beyond a position — a tenant, a filter — must be signed or, better, not be in the cursor at all, since it is exactly the field an attacker would edit.
1const MAX_PAGE = 1002 3type Cursor = { v: 1; createdAt: string; id: string }4 5const encode = (c: Cursor) =>6 Buffer.from(JSON.stringify(c)).toString('base64url')7 8function decode(s: string | undefined): Cursor | null {9 if (!s) return null10 try {11 const c = JSON.parse(Buffer.from(s, 'base64url').toString())12 // validate: it is untrusted input, not a token we issued13 if (c?.v !== 1 || typeof c.id !== 'string') return null14 if (Number.isNaN(Date.parse(c.createdAt))) return null15 return c16 } catch { return null }17}18 19export async function listOrders(tenantId: string, q: Query) {20 // clamp, do not reject: a client asking for 100000 gets 10021 const limit = Math.min(Math.max(q.limit ?? 20, 1), MAX_PAGE)22 const cur = decode(q.cursor)23 24 // fetch limit + 1: the extra row answers "is there more"25 // without a count query26 const rows = await db.query(27 `SELECT id, created_at, total28 FROM orders29 WHERE tenant_id = $1 -- from the session,30 AND ($2::timestamptz IS NULL -- NEVER from the cursor31 OR (created_at, id) < ($2, $3))32 ORDER BY created_at DESC, id DESC33 LIMIT $4`,34 [tenantId, cur?.createdAt ?? null, cur?.id ?? null, limit + 1],35 )36 37 const hasMore = rows.length > limit38 const page = hasMore ? rows.slice(0, limit) : rows39 const last = page.at(-1)40 41 return {42 items: page.map(toDto),43 nextCursor: hasMore && last44 ? encode({ v: 1, createdAt: last.created_at.toISOString(), id: last.id })45 : null,46 }47}Three things carry their weight here. tenantId comes from the session and never from the cursor — a cursor that carried it would be an authorization bypass one base64 edit away (Object-Level Authorization). Fetching limit + 1 answers "is there more" for the cost of one row instead of a count(*). And the version field v is what lets the ordering key change later without every stored cursor becoming a crash.
How to build it
Most important first.
- Default to keyset pagination for anything that can grow: feeds, logs, events, orders, audit trails, and any endpoint an integration will walk end to end.
- Build the cursor from the exact tuple used in
ORDER BY, always ending in a unique column, and create a composite index on that tuple in that order and direction. - Make the cursor opaque — base64 of a small signed or versioned structure — so clients cannot construct one, and so the internal key can change without breaking them. Opaque is not secure by itself; validate it, and never let it widen what the caller may see (Object-Level Authorization).
- Always cap the page size server-side, and clamp rather than error on oversized requests. An unbounded page is a denial-of-service parameter (Resource Limits).
- Return a
nextCursor(null when exhausted) rather than a total-page count. It is the honest shape for a set that changes while it is being read (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design). - Treat counts as a separate, optional and possibly approximate feature. If a UI needs "about 12,000 results", an estimate from statistics is usually adequate and cheap.
- Offer offset only where the data set is small and bounded, or where jumping to an arbitrary page is a genuine requirement — and then cap the maximum offset.
- For full-table walks by an integration, keyset over the primary key is the correct interface and should be the documented one (Batch APIs and Partial Failure in API Design).
What can go wrong
- Deep pages timing out while shallow pages are fast, so the endpoint appears healthy in every average and fails for exactly the users who page far (Percentiles: Which One, and How Many Users Is That? in Observability & Performance).
- An index that does not match the ORDER BY direction or the tiebreaker, so the engine sorts the whole result and keyset performs no better than offset.
- Cursor built from a non-unique column, causing rows to be skipped at page boundaries — a silent data-loss bug in any consumer that walks the whole set.
- A cursor that encodes a filter the client can tamper with, letting a caller widen the query beyond what they may see.
- Clients that store cursors for days against data whose ordering key has since changed.
- Keyset applied to a query with a complex sort or a computed ordering expression, where the tuple comparison cannot use an index at all.
- Rows inserted or deleted while a client pages shift every subsequent offset, so items are skipped or repeated. Keyset is immune to inserts before the cursor and still sees deletions.
- A row updated so that its sort key changes can move across the cursor boundary and be visited twice or never — consumers walking a whole table must be idempotent (Idempotency in Backends).
- A long-running walk can outlive the data it started on; for exact snapshots the right tool is a repeatable-read transaction or a change feed, not pagination (Isolation Levels in Database Engineering).
- A cursor is client-supplied input. Validate and, where it encodes anything beyond a position, sign it — never trust its contents to scope a query (The Trust Boundary).
- Authorization must be applied to every page independently. A cursor obtained while the caller had access must not continue to work after that access is removed (Object-Level Authorization).
- Unbounded page size is a resource-exhaustion vector; the cap belongs on the server, because the client's value is a request, not a limit (Rate Limiting).
- Sequential integer cursors leak volume and ordering information about other tenants' data; opaque cursors over non-guessable keys avoid the inference (Multi-Tenancy).
- "Add an index and offset is fine." The index fixes the ordering, not the discarding. The engine still walks every skipped index entry, and the cost still grows with depth.
- "Cursor pagination is just offset with the number hidden." It is a different operation: a seek to a value rather than a count of rows to skip. That is why its cost does not grow.
- "We need a total count for the UI." Usually the UI needs "are there more" — which
nextCursoranswers for free — or an approximate figure, which is far cheaper. - "Pagination is an API concern." The contract is API Design's; the query plan, the index and the deep-page cost are the backend's, and the contract you expose determines which query you are forced to run (Pagination: Choosing How Lists End in API Design).
- "Sorting by
created_atis deterministic." Not if two rows share a timestamp. Every cursor needs a unique tiebreaker.
Operating it
- Record the offset or page depth as a dimension on the endpoint's latency metric. Without it, the deep-page problem is averaged out of existence (Label Sets That Survive a Year in Observability & Performance).
- Log queries whose examined-rows count vastly exceeds their returned-rows count — the direct signature of offset scanning (Reading EXPLAIN ANALYZE in Database Engineering).
- Track the distribution of requested page sizes to see whether clients are trying to exceed the cap.
- Count requests with no pagination parameters at all; an endpoint returning an unbounded list is a future incident (Unbounded Collections: The Anti-Pattern With a Fuse in API Design).
- Offset degrades with table size and with depth simultaneously, so an endpoint that was fine at a hundred thousand rows is a problem at ten million with no code change.
- Keyset is flat in depth: the cost of a page depends on the page size, not on how far in it is. That is the entire reason to prefer it.
- At very large scale even keyset needs care: the ordering index must fit the access pattern, and a query that filters on one column and orders by another may need a composite index designed for exactly this (Composite Indexes and the Leftmost-Prefix Rule in Database Engineering).
- Total counts do not scale at all. At some size the honest options are an estimate, a cached count, or no count.
- Keyset gives up jumping to an arbitrary page and page-number UIs. That is a real product constraint, and infinite-scroll or next/previous is usually an acceptable and often better interface.
- Cursors are opaque, so debugging a client's position requires decoding them, and a cursor cannot be constructed by hand in a test without a helper.
- Offset is genuinely simpler and correct for small, bounded, mostly-static sets. Using keyset everywhere is a cost with no benefit for a settings list of twelve rows.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe offset-versus-keyset trade holds for any ordered data source, including document stores and search engines.
- DATABASE-SPECIFICRow-tuple comparison
(a, b) > (x, y)is standard SQL and used well by Postgres with a matching composite index; MySQL historically optimises it less predictably and may need the comparison expanded into an OR form. Some engines cap deep offsets outright, and search engines usually require a cursor-style API past a fixed depth. - SCALE-SPECIFICBelow a few thousand rows the difference is unmeasurable and offset is the simpler code. The decision point is whether the set can grow without bound, not its size today.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.