PATTERN Cited by 1 source
Conditional optimisation by page depth¶
Pattern: when a cost transformation is a win in one regime but a loss in another (e.g. fast at page 100, slower at page 1), do not apply it unconditionally. Gate it behind a runtime check on the variable that controls which regime the request lives in (here: the page number), and measure the crossover empirically on the actual workload.
The canonical formulation for pagination:
posts = Post.all.page(params[:page]).per(25)
# Use fast page after page 5, improves query performance
posts = posts.fast_page if params[:page] > 5
(Source: sources/2026-04-21-planetscale-introducing-fastpage-faster-offset-pagination-for-rails-apps.)
Why the pattern exists¶
The deferred-join rewrite exchanges one wide query for two narrow ones. The cost structure is:
baseline_cost(offset, limit) = 1 × (secondary_walk + clustered_hydrate) × (offset + limit)
deferred_cost(offset, limit) = 1 × secondary_walk × (offset + limit) # phase 1: id-only
+ 1 × clustered_hydrate × limit # phase 2: hydrate
+ 2 × round_trip # two queries
At shallow offsets (offset + limit small), the two
round-trips and extra planner invocation dominate the
saved hydrate work and the rewrite is slower. Past a
crossover depth the saved hydrates dominate and the
rewrite wins increasingly.
The crossover is a function of:
- Row size — wider rows make hydrate cost dominate sooner (crossover earlier).
- Network latency — higher RTT makes the two-round- trip tax larger (crossover later).
- Index depth — deeper indexes shift the constants modestly.
- Workload mix — cached vs cold buffer pool moves both sides.
Coutermarsh recommends page 5 as a starting heuristic but explicitly delegates the measurement to the reader: "You should test it on your application's data to see how it improves your query times."
The general shape¶
Any cost transformation with asymmetric behaviour by input regime fits the pattern. Examples beyond pagination:
- Small-batch vs large-batch: batch processing has per-batch overhead; at small batch sizes a single-item fast path may be faster.
- Vectorised vs scalar ops: SIMD/vectorisation has fixed setup cost; useful only past a threshold of element count.
- Compressed vs uncompressed transfer: gzip has per-request CPU overhead; for tiny payloads the wire savings are smaller than the CPU cost.
- Cache-warming precompute vs on-demand: precompute amortises only if request rate exceeds a threshold.
- Cursor vs offset pagination: cursor pagination is always faster than offset, but the UX cost of losing skip-to-page-N is only worth paying past a depth threshold where the offset cost starts to hurt.
Canonical signatures:
# Python
result = fast_path(...) if input.is_in_fast_regime() else default_path(...)
# SQL planner directive (Postgres)
/*+ IndexScan(posts created_at_idx) */ -- for deep-page queries only
# ORM-level gate (Rails)
posts = posts.fast_page if params[:page] > 5
Measurement discipline¶
Coutermarsh's pattern has four explicit measurement steps worth canonicalising:
- Identify the axis that selects the regime. For
pagination it's
params[:page]/ offset depth. For compression it's payload size. For cursor-vs-offset it's UX expectation + page depth. - Measure both paths at multiple points on the axis. Don't assume the crossover is uniform — it depends on data distribution. Test at page 1, 5, 10, 50, 100, 500, 2000 and look for the intersection.
- Make the threshold a configuration, not a constant.
Coutermarsh's
> 5is a literal in the code sample but could reasonably be a per-app config because the crossover depends on row width and RTT. - Monitor for regime drift. As data grows, index depths change, buffer-pool hit rates drift, and the crossover shifts. Periodic re-measurement is part of the pattern's correct application.
Naïve vs correct application¶
Naïve: apply the optimisation everywhere because it's "faster".
# Wrong: unconditional .fast_page is slower at small offsets
posts = Post.all.page(params[:page]).per(25).fast_page
Correct: gate on the regime-selection axis.
# Right: conditional on page depth
posts = Post.all.page(params[:page]).per(25)
posts = posts.fast_page if params[:page] > 5
The naïve form is common when engineers see a benchmark showing a 2.7× speedup and don't read the fine print. Coutermarsh's post is unusual in that it warns about this directly in the post-introducing-the-optimisation, not in a separate "gotchas" post six months later.
Anti-patterns¶
- Applying the new path unconditionally after seeing a single impressive benchmark. Most optimisations have a crossover.
- Hard-coding the threshold as a literal in the application code. Treat it like a timeout or batch size — configurable, measured, monitored.
- "Optimising" a path that's already fast enough. For
apps where
OFFSETnever exceeds 100, the deferred-join rewrite adds complexity for no gain. The pattern only matters if the deep-page case is actually hit in production. - Ignoring operational dimensions. The optimisation might improve p50 but worsen p99 if the second query in a two-query path can stall on connection-pool contention. Measure tail latency, not just mean.
Relationship to other patterns¶
- patterns/deferred-join-for-offset-pagination — the specific instance this pattern was canonicalised against.
- concepts/offset-pagination-cost — the cost model the pattern's threshold is calibrating against.
- concepts/cursor-pagination — the strict-dominance alternative that removes the threshold question entirely at the UX cost of losing random-page access.
Seen in¶
- sources/2026-04-21-planetscale-introducing-fastpage-faster-offset-pagination-for-rails-apps
— canonical wiki source. Mike Coutermarsh's
params[:page] > 5gate is the pattern's literal reference formulation. Warning voice in-post, not post- hoc: "Becausefast_pageruns 2 queries instead of 1, it is very likely a bit slower for early pages. The benefits begin as the user gets into deeper pages. It's worth testing to see at which page your application gets faster from usingfast_pageand only applying to your queries then."