skip to content

A team insists every paginated list response must include an exact match count — as an `X-Total-Count` response header or a `meta.total` field. What does that requirement cost in production, and what alternatives would you offer them?

level: seniorimportance: should knowfreq 48%

answer

  1. has_more = O(page); total = O(matches)
  2. stale the moment it is computed
  3. opt-in ?include_total=true
  4. cap at N+1 → "10,000+"
  5. totals lock you into offset paging

basics

~20 s

An exact count means a second scan of the whole filtered set on every page request — cost grows with the result size, not the page size. Offer it as an opt-in parameter, an approximate/capped count, or just has_more plus a next link.

solid answer

~60 s

The cost is that `has_more` is O(page) while an exact total is O(matches). Serving page 1 of 20 items may touch 20 rows; counting matches may touch millions, on every single page request, and the count is stale the moment it is computed. It also blocks cursor pagination's main benefit and is the classic source of "the list endpoint got slow when we added a filter". Alternatives, in the order I'd offer them: 1. **`has_more` / `rel="next"` only** — enough for "load more" and infinite scroll, which is most consumers. 2. **Opt-in count**: `?include_total=true`, so the expensive path is chosen by the caller and visible in metrics. 3. **Capped count**: count up to N+1 and return `"1000+"` — bounded work, and honest UI copy. 4. **Approximate count** from statistics or a maintained counter, labelled `approximate: true`. 5. **Cached count** per filter combination with a short TTL, for dashboards. The real question to the team is *what the number is for*: page-number UI, a progress bar, or an analytics figure. Each has a cheaper answer than an exact per-request count.

code

http · 11 lines
http
GET /v1/orders?status=open&limit=20&include_total=true HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Expose-Headers: X-Total-Count
X-Total-Count: 10000

{
  "data": [ ... 20 orders ... ],
  "meta": { "has_more": true, "total_count": 10000, "total_is_capped": true }
}

go deeper

for a junior

Know that has_more is cheap and an exact total is not, and that limit + 1 is the standard trick for has_more.

for a middle

Explain the O(page) vs O(matches) asymmetry, capping, and where the number lives (header vs meta) including the CORS exposure detail.

for a senior

Drive the conversation to the requirement behind the number, offer the opt-in/capped/approximate ladder, and cover instrumentation, timeouts, and degradation under load.

for a principal

Treat it as a contract commitment: exact totals constrain the API to offset paging for its lifetime, so decide deliberately, set an estate-wide default of has_more, and route true count requirements to a reporting surface with its own SLA.

## Why the count is the expensive part Returning a page is bounded work: you fetch `limit` rows plus, typically, one extra to decide `has_more`. Returning an exact total is unbounded: the server must evaluate the full filter predicate against the entire collection, every time, because the answer depends on the whole set and not on the slice you are returning. A request for 20 rows can trigger work proportional to ten million matches. The asymmetry is the entire answer, and it is what an interviewer is listening for. A second, subtler cost: totals fight pagination strategy. If you commit to `total` and `total_pages`, you have implicitly committed to offset-style paging with a fixed page index — which is exactly the model that degrades at deep offsets and produces unstable pages under concurrent writes. Teams that hardwire an exact total into their public contract often find they cannot move to cursors later without a breaking change. Third: the number is a lie by the time it arrives. Under concurrent writes, `total` reflects a moment that has already passed; a UI that renders "Page 3 of 417" is presenting a snapshot with no consistency guarantee against the pages the user actually receives. ## Where to put it, if you do return it Two conventions exist. `X-Total-Count` is a de-facto header (not standardized; the `X-` prefix is discouraged by RFC 6648, so `Total-Count` or a `meta` field is arguably cleaner). Or a body field, `meta.total_count`. Headers have the advantage of being cheap for clients that only need the number (paired with a `HEAD` request or `limit=0`), and they keep working when the body is not JSON. Body fields are easier for browser clients — a header needs `Access-Control-Expose-Headers` to be readable cross-origin. Pick one and use it consistently; do not emit both. ## The ladder of alternatives **Boolean more-pages.** `has_more: true`, or the presence of `rel="next"` in the `Link` header. Costs one extra row fetched. Satisfies infinite scroll, "Load more" buttons, and every batch client that iterates until exhaustion — in practice the large majority of consumers. **Opt-in exact count.** `GET /orders?include_total=true`. The expensive query runs only when asked for, it shows up in metrics attributable to a caller, and it can be rate-limited or restricted to specific API keys. This is the pragmatic compromise to offer a team that says "we need it" — usually one screen does, and every other caller stops paying for it. **Capped count.** Count with a limit of, say, 10 000 and return `{ "total": 10000, "total_is_capped": true }`, rendered in the UI as "10,000+ results". Search engines have trained users to accept this. Work is bounded by the cap regardless of collection size. **Approximate count.** Use table statistics, a maintained counter row, or a probabilistic estimate, and label it explicitly (`"approximate": true`). Good for "about 4.2M results" headlines, unacceptable for anything reconciled against money. **Cached count.** Compute per filter-combination on a schedule or with a short TTL. Works when the filter space is small (a handful of dashboard views); collapses when filters are free-form because the cache key space explodes. ## Running the conversation Ask what the number is used for. "Render page-number buttons" → the whole page-number UI is the thing to challenge; infinite scroll or next/prev removes the requirement entirely. "Show the user how many results matched" → capped or approximate is fine, and better UX than a slow exact number. "Reconcile a financial report" → that is an analytics/reporting query, not a list endpoint; serve it from a reporting path with its own SLA rather than making every list request pay. Operationally: if you already serve exact totals, instrument them separately from the page fetch, so the count's latency is visible; add a timeout that degrades to `has_more` rather than failing the request; and never let an uncapped count run inside the request path of an endpoint that also serves interactive traffic under load. ## What a strong answer sounds like "`has_more` is O(page), an exact total is O(matches), and I don't want every caller paying that for a number that's stale on arrival. I'd default to `has_more` plus a `next` link, offer `include_total=true` for the one screen that needs it, and cap it at 10k with a `10,000+` display — then ask what the number is actually for, because page-number UI is usually the requirement in disguise."

  • How do you compute `has_more` without a count query?
    Request `limit + 1` rows. If you get back more than `limit`, there is at least one further row: drop the extra, return `limit` items and set `has_more: true`. The cost is one additional row read, independent of how large the matching set is.
  • The product team wants page-number buttons — "Page 7 of 214". How do you push back?
    Point out that the page count forces an exact total on every request and pins you to offset paging, which also duplicates and skips rows under concurrent writes. Offer next/prev navigation or infinite scroll, and if a jump-to-position affordance is genuinely needed, offer jumping by a meaningful key — a date or a first letter — instead of an arbitrary page index.

Handing someone the next ten books off a shelf is quick; telling them exactly how many books in the whole library match their topic means walking every aisle — and someone is still adding books while you count.

saying these in an interview costs you the question

  • Assuming an exact count is cheap because "the database can just count rows" — ignoring that the cost scales with matches, not with page size
  • Returning `total` while claiming the API is cursor-paginated and scale-friendly
  • Presenting a total as consistent with the pages returned, under concurrent writes
  • Caching counts per free-form filter combination without noticing the cache key space is unbounded
  • Adding `X-Total-Count` and being surprised browser JavaScript cannot read it (no `Access-Control-Expose-Headers`)

context