Skip to content

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:

  1. 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.
  2. 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.
  3. Make the threshold a configuration, not a constant. Coutermarsh's > 5 is a literal in the code sample but could reasonably be a per-app config because the crossover depends on row width and RTT.
  4. 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 OFFSET never 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

Seen in

  • sources/2026-04-21-planetscale-introducing-fastpage-faster-offset-pagination-for-rails-appscanonical wiki source. Mike Coutermarsh's params[:page] > 5 gate is the pattern's literal reference formulation. Warning voice in-post, not post- hoc: "Because fast_page runs 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 using fast_page and only applying to your queries then."
Last updated · 378 distilled / 1,213 read