Skip to content

measured · · 12 min

Making a list endpoint cost O(page), not O(table)

Five techniques for read paths that stop scaling: keyset pagination instead of OFFSET, bounded counts instead of exact ones, why a covering index still hits the heap, precomputed read models and the read:write ratio that justifies them, and the scope check that decides when a cached aggregate is legal to serve.

A list endpoint has a property that makes it uniquely good at hiding its own cost: the response size is constant. Twenty rows on page one, twenty rows on page five hundred, twenty rows whether the table holds a thousand records or four million. Every signal the client can see stays flat while the work behind it grows without limit.

So these endpoints pass review, pass staging, pass the first year, and then become the slowest thing you own — not because anyone wrote bad code, but because four separate parts of a normal implementation are all O(table) and none of them announce it.

This post is the technique catalogue, not an incident report. Each section is a pattern with a decision rule and the shape of the code, in the order I would apply them to an endpoint that has started to drift. The examples are Postgres and Rails; the reasoning holds for MySQL, and for any ORM that gave you page and per_page and let you stop thinking about it.

Related reading in this series: a latency budget you can defend for deciding what any of this should cost, and Little's Law in one afternoon for what happens to the queue when it doesn't.

1. OFFSET does not skip rows

The first thing to fix, because it is the least ambiguous.

OFFSET 10000 does not tell the database to start at row 10,001. There is no such instruction. The executor produces rows from the start of the ordering, counts them, discards them, and begins returning once the count is satisfied. The work is proportional to how deep the page is.

figure 1 · rows touched to return one page of 20
20 per page
1
OFFSET / LIMITLIMIT 20 OFFSET 0
20 rows read
Keyset (seek)WHERE id > :cursor ORDER BY id LIMIT 20
20 rows read

On page one the two are identical. This is why the problem never shows up in development, where nobody pages past the first screen.

Drag to a deep page. The response is the same size at every setting — twenty rows — but OFFSET reaches it by walking everything in front of it and throwing the result away. Keyset asks the index to start in the right place, so its cost is flat.

The replacement is a seek, usually called keyset or cursor pagination: carry the last row's sort key forward and ask for what comes after it.

terminal
-- page N, cost grows with N
SELECT * FROM records
 WHERE tenant_id = $1
 ORDER BY id
 LIMIT 20 OFFSET $2;

-- any page, constant cost
SELECT * FROM records
 WHERE tenant_id = $1 AND id > $2   -- $2 = last id of the previous page
 ORDER BY id
 LIMIT 20;

Three practical notes that decide whether this works in production:

The cursor must be unique and in the sort order. Paginating by created_at alone breaks on ties — two rows with the same timestamp straddle a page boundary and one of them is never returned. Sort by (created_at, id) and carry both: WHERE (created_at, id) > ($1, $2). Row comparison is standard SQL and Postgres will use a composite index on (created_at, id) for it.

Your index must match the sort exactly, including direction. ORDER BY created_at DESC, id DESC wants an index declared DESC, DESC — or one the planner can read backwards, which it can only do when the directions are consistent with each other.

You lose random access, and that is usually fine. Keyset gives you next and previous, not "jump to page 47". Check who actually uses deep page numbers. Every time I have looked, the answer was a crawler and a script, and the product answer was a better filter rather than a deeper page.

Rule: if a list can exceed a few thousand rows, it is keyset. Offset is for bounded sets you can prove stay small.

2. The count is a second query, and nobody reads the number

Here is the cost people miss entirely. A paginated response typically renders "Showing 1–20 of 4,182", and that total is a separate aggregate over the whole filtered set, executed on every single request. It cannot use the LIMIT. It has no upper bound. It is frequently more expensive than fetching the page.

Then ask what the number is for. Users read it as an order of magnitude — "is this dozens or thousands?" — and to decide whether to narrow the filter. Nobody needs to know that it is 4,182 rather than 4,180. Past a few pages, the exact value is a scan you pay for to render a string nobody parses.

figure 2 · what one pagination count costs
cap 10,000
1,284

Exact count

SELECT COUNT(*) FROM matches
rows examined
1,284 · ~32 ms
user sees
1,284

Bounded count

SELECT COUNT(*) FROM (… LIMIT 10,001)
rows examined
1,284 · ~32 ms
user sees
1,284

Below the cap the two are identical in cost and in output. The bound only engages where the exact number stopped being information and started being a scan.

The count is not the page. It is a second query that runs on every request purely to render 'of N'. Past the cap, the bounded form stops early and reports a floor — which is what every large site you use already does.

The technique is to push a LIMIT inside the count and report a floor:

terminal
SELECT COUNT(*) FROM (
  SELECT 1 FROM records
   WHERE tenant_id = $1 AND status = $2
   LIMIT 10001                     -- cap + 1
) counted;

The + 1 is the whole trick: it tells you whether you hit the cap without telling you how far past it you are, which is exactly the information the + in "10,000+" encodes. GitHub, Google and Jira all do a version of this. It is not a degradation, it is the correct interface for an unbounded set.

Two things to get right when you wire it up:

terminal
# The count must not inherit the page's LIMIT/OFFSET, and must not inherit
# its ORDER BY either — sorting a set you are only going to count is pure cost,
# and it can push the planner onto a completely different index.
scope = page.except(:limit, :offset).unscope(:order)
n = scope.limit(CAP + 1).count(:all)
capped = n > CAP

And check what your pagination library does with the total, because the derived fields go wrong quietly:

terminal
# total_pages is computed from total_count. Hand it a capped count and it
# reports a capped page count — which is correct and consistent, as long as
# your UI does not also promise a "last page" button that now lies.
total_pages = (total.to_f / per_page).ceil

Rule: exact counts are for sets with a natural ceiling — a user's API keys, a deal's line items. Anything that grows with the tenant gets a bounded count and a +.

3. A covering index is not a licence to skip the table

This is the one that surprises people who have already done the reading, because the plan says the optimisation is working and the timing says it isn't.

Postgres can answer a query from the index alone — an index-only scan — but only when it can prove the rows are visible to your transaction without looking. That proof comes from the visibility map, a bitmap that marks heap pages as all-visible, and it is maintained by VACUUM. For any page not marked, the scan must fetch from the heap to check row visibility. EXPLAIN (ANALYZE) reports those as Heap Fetches.

figure 3 · the scan head and the visibility map
  • not yet reached
  • all-visible · index only
  • dirty · heap fetch (↓)
pages scanned
0 / 28
heap fetches
0
served from index alone
100%

A table rewritten every hour is a table VACUUM is always behind on.

Run it with the hourly rewrite on, then turn it off and run it again. The index is identical in both passes. The only thing that changed is whether VACUUM had time to mark pages all-visible — and that is what decides whether an index-only scan touches the table.

So an index-only scan is not a property of your index. It is a property of your index and your write pattern. A table that is fully rewritten on a schedule — an hourly rollup, a nightly upsert, a sync job — keeps dirtying pages faster than autovacuum marks them clean, and the scan degrades into doing both jobs at once. You will see a plan that reads like a win, with Heap Fetches in the tens of thousands and a runtime that says otherwise.

The related trap is the INCLUDE clause:

terminal
CREATE INDEX idx_records_agg
    ON records (tenant_id, status)
INCLUDE (score, updated_at);

INCLUDE columns are stored in the leaf pages and are readable but not searchable. They can satisfy a SELECT list; they cannot satisfy a WHERE clause or an ORDER BY. A filter on score will not use this index no matter how much the column looks present. Everything you filter, join or sort on belongs in the key columns — in selectivity order — and INCLUDE is only ever for payload you return.

Rule: before adding an index for an aggregate, check the write pattern of the table. If it is rewritten faster than it can be vacuumed, no index will fix the read — go to technique 4.

4. Stop deriving the number; store it

Once the table is too hot to index your way out of, the remaining move is to stop computing the figure per request and compute it per change instead. This is the read-model idea from CQRS, and you do not need the rest of CQRS to use it: one small table, one row per (scope, subject), a JSON payload of the figures, refreshed by the job that already writes the underlying data.

terminal
CREATE TABLE rollups (
  id           bigserial PRIMARY KEY,
  tenant_id    bigint      NOT NULL,
  subject_type varchar(32) NOT NULL,
  payload      jsonb       NOT NULL DEFAULT '{}',
  computed_at  timestamptz NOT NULL
);
CREATE UNIQUE INDEX idx_rollups_subject ON rollups (tenant_id, subject_type);

The economics are a read:write ratio and nothing else:

figure 5 · seconds of database time per minute
40 writes/min
120
Derive on every request
21.6s / min

120 × 180 ms scan

Precompute on write, read by keycheaper here
7.2s / min

40 × 180 ms scan + 120 × 0.4 ms lookup

At 120 reads/min, deriving costs 21.6s of database time per minute and precomputing costs 7.2s. Precomputing crossed ahead at about 41 reads/min and stays flat from here.

Sweep the read rate. Deriving scales with reads; precomputing scales with writes and is flat in reads. The crossover sits wherever reads overtake writes — for most list endpoints that is a handful of requests a minute, which is why 'just cache it' is usually right and 'it is only one query' usually isn't.

Two design rules keep this from rotting, and they matter more than the schema.

One definition per figure, shared by both paths. The moment the writer computes "open deals" one way and the live fallback computes it another, you have two answers to one question and a bug that only appears when the rollup is stale. Put each figure in exactly one place and have both the writer and the fallback call it:

terminal
class RollupWriter
  # The single definition. The writer stores this; the read path's fallback
  # calls the same method. There is no second implementation to drift.
  def self.score_tally(scope)
    scope.group(:segment).count
  end
end

Staleness is a policy, not an accident. Store computed_at, define what stale means, and decide what a stale read does — serve it and refresh in the background is usually right for a dashboard; fall through to the live query is right for anything a user is about to act on. Write it down in the model:

terminal
STALE_AFTER = 6.hours
def stale? = computed_at < STALE_AFTER.ago

Rule: precompute when reads outnumber writes and the aggregate is over a set the request does not narrow. That second clause is the whole of the next section.

This is the technique I see skipped most often, and it is the one that turns a cache into a correctness bug rather than a stale number.

A rollup answers a specific question: what is true across this whole scope? A request that adds any narrowing — an owner filter, a team-scoped permission, a "hide archived" toggle — is asking a different question. Serving the rollup to it is not a cache hit. It is a wrong number, rendered with total confidence, and it will be wrong in the direction of "too big", which is the direction people do not question.

figure 4 · when you are allowed to serve the precomputed number
what this request adds
scope of this request
SELECT … FROM "records" WHERE "records"."tenant_id" = 1
scope the rollup was built from
SELECT … FROM "records" WHERE "records"."tenant_id" = 1

scopes match · serve the rollup

One primary-key read replaces the aggregate. This is the common case: most requests do not narrow anything.

Toggle any narrowing. The gate is not a heuristic or a TTL — it is string equality between the SQL this request would run and the SQL the rollup was built from. Equal means the question is the same question. Anything else falls through to the live aggregate.

The robust check is not a list of known-safe filters, because that list goes stale the moment someone adds a sixth one and forgets. Compare the generated SQL of the request's scope against the scope the rollup was built from:

terminal
# Admissible only when this request's visible set is exactly the set the
# rollup was computed over. String equality on generated SQL is crude and
# that is the point: anything that narrows the scope changes the string, so
# a filter added next year fails closed without anyone remembering this file.
def unrestricted?(type)
  subject_scope(type).to_sql == tenant_scope(type).to_sql
end

def rollup_for(type)
  return nil unless unrestricted?(type)
  fetch_rollup(type)
end

Fails closed is the property worth paying for. A new filter, a new policy, a new role — all of them make the strings differ, the gate returns nil, and the endpoint falls back to the live aggregate. The failure mode is "slower than it could be", not "confidently wrong". When you are choosing between two designs for a cache, choose the one whose unknown-unknowns land on the slow side.

For aggregates that span several subjects, every one of them has to clear the gate:

terminal
def insight_rollup
  return nil unless %w[Contact Company Deal].all? { |t| unrestricted?(t) }
  fetch_rollup("Insight")
end

Refactor order

Applying these to an endpoint already in production, cheapest and safest first:

#TechniqueEffortRiskDo it when
1Drop ORDER BY from the countminutesnonealways
2Bounded count with a +an hourUI copy onlythe set can exceed a few thousand
3Keyset paginationa daychanges the API's cursordeep pages exist, or an export walks them
4Precomputed read modela sprintstaleness policy, a new tablereads ≫ writes and the aggregate is hot
5Scope-equality gatea daynone if it fails closedthe instant you ship #4

Do not start at 4. A read model built over an endpoint that still sorts its count and offsets its pages is a second system maintaining a first system's waste.

What to measure

Numbers that tell you the endpoint is drifting, before a user does:

  • Rows examined per row returned. The single best ratio. A healthy page reads tens of rows to return twenty. Anything in the thousands is technique 1 or 2 waiting to happen.
  • Heap Fetches on any plan you believe is index-only. If it is not near zero, you do not have an index-only scan, whatever the node is called.
  • Count-query time as a share of endpoint time. Above about 30% and the total is costing more than the data.
  • Rollup hit rate, split by reason for a miss — stale versus scope-gated. A high scope-gated rate is not a bug, it is the gate telling you your users filter more than you assumed, which is a product finding.
  • p95 by page depth, not just p95. The average page is page one. The complaint is always about page forty.

If you only instrument one of those, take the first. Rows examined per row returned is the number that separates an endpoint that will be fine at ten times the data from one that will not, and it is visible in a single EXPLAIN (ANALYZE, BUFFERS) long before it is visible in a dashboard.

related_reading

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.