CONCEPT Cited by 1 source
Offset pagination cost¶
The runtime cost of SELECT … ORDER BY key LIMIT N OFFSET M in
most relational databases is proportional to M + N, not
N. OFFSET is implemented as fetch the first M+N rows in
order, then discard the first M — not as a seek into a sorted
structure. This means every page the user paginates past has
to be fully materialised by the database to be thrown away,
and the work the database does for "page 100" is ~100× the work
it does for "page 1."
(Source: sources/2026-04-21-planetscale-introducing-fastpage-faster-offset-pagination-for-rails-apps.)
Why the cost scales with M + N, not N¶
On a row-store database like MySQL with InnoDB's clustered-index layout:
The execution is:
- Walk the secondary index on
created_atin descending order. - For each index entry, take the primary-key pointer in the
index leaf and descend the
clustered index to hydrate the full row (
SELECT *). - Discard the first 100 hydrated rows.
- Return the next 25.
The database paid for 125 secondary-index walks + 125
clustered-index lookups + 125 full-row hydrations to return
25 rows. For offset N, the count is M + N. At OFFSET 100,000
LIMIT 25 the database hydrates 100,025 full rows, throws away
100,000, and ships 25.
Empirical measurement¶
Coutermarsh's FastPage post benchmarks on a ~1M-row Rails Post
table:
| Query | Time |
|---|---|
Post.all.order(created_at: :desc).limit(25).offset(100) |
1,228.7 ms |
| Same query with deferred join rewrite | ~457.3 ms |
The slow version is the baseline: it hydrates 125 full rows from the clustered index. The fast version hydrates only 25 — exactly the page size — after a thin index-only pre-scan to select the correct primary keys.
Coutermarsh's 2000-page benchmark sweep shows the baseline cost climbing steeply with depth while the deferred-join rewrite stays near-flat. The gap widens superlinearly because the unoptimised query's hydration cost is itself a function of offset depth.
Why this is counterintuitive¶
Developers tend to reason about LIMIT 25 OFFSET 100 as "give
me 25 rows starting at position 100" and assume the database
seeks to position 100. That intuition is wrong for most row
stores:
- There is no sorted structure to seek into unless there's
an index whose order matches the
ORDER BY. - Even with such an index,
OFFSETis the skip-count operator, not a seek-key operator. The database still walks the indexM + Nentries. - Seeking by sort key —
WHERE created_at < $last_seen_timestamp ORDER BY created_at DESC LIMIT 25— is the operation the developer wanted, but that's cursor pagination, not offset pagination.
Three standard mitigations¶
- Cap the
max page — many applications forbid paginating past
page 100 (
OFFSET 10,000) to bound worst-case cost. Simple but sacrifices UX for deep catalogs. - Rewrite to cursor pagination — O(1) at any depth, but loses skip-to-arbitrary-page-N capability.
- Rewrite to deferred join —
preserves offset UX, turns the full-row hydration cost into
an index-only scan for the first
M + Nrows. Still O(M + N) but with a much smaller constant because no full rows are hydrated until the finalLIMIT N.
The choice depends on the UX constraint:
| UX needed | Solution |
|---|---|
| "Next" / "Previous" buttons only | cursor pagination (best) |
| "Go to page 47" (random access) | deferred join (middle ground) |
| No deep pagination at all | cap max page |
Relationship to indexes¶
The cost can sometimes be reduced — not eliminated — by index design:
- Covering index for the sort: if the sort column is
indexed and the
SELECTlist is narrow, the query planner may avoid clustered-index lookups. This is the exact mechanism the deferred join forces whenSELECT *precludes a natural covering-index plan. - Composite index matching the predicate + sort order:
for
WHERE owner = ? ORDER BY created_at DESC LIMIT N OFFSET M, a composite index on(owner, created_at)lets the planner walk a narrow contiguous slice of the index rather than scanning the wholecreated_atindex and post-filtering byowner. The deferred-join first query leans on this. - Still O(M + N): none of these tricks change the asymptotic cost; they only lower the constant. For very deep pagination (OFFSET > 100k) you need a fundamentally different mechanism.
Seen in¶
- sources/2026-04-21-planetscale-introducing-fastpage-faster-offset-pagination-for-rails-apps
— Mike Coutermarsh's introduction of PlanetScale's
fast_pagegem. Canonical wiki source for the OFFSET-cost model with quantified benchmark (1,228.7 ms → 457.3 ms, 2.7× at offset 100; flat line over a 2000-page sweep). Pedagogical framing cites High Performance MySQL (Schwartz / Zaitsev / Tkachenko): "it lets the server examine as little data as possible in an index without accessing rows."