skip to content

Offset vs Cursor Pagination

The contract-level tradeoff between page/offset params and opaque cursors: result stability under concurrent inserts, deep-page cost, and client ergonomics. One of the most common API-design interview questions because it links contract choices to database behavior.

part ofAPI stylesoverview, primer and where to startread it →
on this pageshow

questions

3

A REST list endpoint accepts a `limit` (or `size`) query parameter. What should the API do when a client omits it, sends `limit=0`, sends `limit=100000`, or sends something non-numeric?

level: juniorimportance: must knowfreq 52%

answer

  1. default 20-50, never unbounded
  2. max derived from latency + payload budget
  3. over max: clamp or 400 — but document it
  4. 0 / negative / non-numeric → 400
  5. echo page_size + has_more, never infer the end from a short page

basics

~20 s

Omitted: apply a documented default (often 20-50) — never "everything". Over the maximum: either clamp to the max or return 400, but document which. Non-numeric or negative: 400. Echo the effective limit in the response so the client can see what actually applied.

solid answer

~60 s

Every list endpoint needs three documented numbers: **default**, **maximum**, and what happens outside them. - **Omitted** → the documented default (20-50 is typical). Never unbounded: an unpaginated collection endpoint is a latency and memory incident waiting for the collection to grow. - **Above the maximum** → pick one behaviour and document it. Clamping to the max is forgiving and common (Stripe caps at 100); rejecting with `400` is more honest because the client learns it did not get what it asked for. The failure mode to avoid is honouring it. - **`limit=0`** → either `400`, or a defined meaning such as "metadata only, no items" if you support a count-only mode. Undefined behaviour here is a common source of infinite client loops. - **Non-numeric or negative** → `400` with a message naming the parameter. Always return the effective page size in the response (`meta.page_size`), because a silently clamped limit otherwise looks to the client like the end of data. And the maximum should be derived from real response-size and latency budgets, not chosen because it is round.

code

http · 9 lines
http
GET /v1/orders?limit=100000 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [ ... 100 items ... ],
  "meta": { "page_size": 100, "requested_limit": 100000, "has_more": true }
}

go deeper

for a junior

State the default/maximum/validation rules and that unbounded responses are never acceptable.

for a middle

Explain clamp-versus-400 and why the effective size plus an explicit has_more must be returned.

for a senior

Derive the cap from latency and payload budgets, account for expansion cost, and treat the cap as a stability control not subject to per-customer exceptions.

for a principal

Standardize parameter names, defaults and caps across the API estate, and set the policy for how caps are revisited as resources and clients grow.

## Why this small question is asked It is a cheap way to find out whether a candidate thinks about the contract's edges. The happy path is trivial; the interesting behaviour is at the boundaries, and the boundary bugs are the ones that page an on-call engineer. ## The default When the client sends no size, the server picks. The wrong answer is "return everything" — an endpoint that returns the whole collection works perfectly in development against fifty rows and takes the service down when a customer reaches five hundred thousand. Pagination is not an optimization to add later; unbounded reads become load-bearing in client code the moment they exist, and removing them is then a breaking change. Pick a default that keeps a typical response comfortably inside your latency and payload budget: 20-50 items is the common band. Document it, and treat it as part of the contract — clients will build UIs assuming it. ## The maximum The maximum exists because response size and query cost scale with it. Derive it from a real budget: a target p99 latency and a target maximum serialized response size, divided by the typical item size. A 100-field resource and a 5-field resource do not deserve the same cap. Two defensible behaviours above the cap: - **Clamp silently to the maximum.** Forgiving; the client still gets data. This is Stripe's style (`limit` is capped at 100). The risk is that a naive client asking for 1000 gets 100, sees fewer items than requested, concludes "that's the end", and stops — which is why you must return the effective size and a `has_more` flag or `next` link. - **Reject with `400`.** Explicit; the client learns immediately. Better for internal APIs and for clients you can fix. What is not defensible is honouring an arbitrary limit "just this once" for a big customer: the cap is a stability control, and per-caller exceptions are how a service discovers its worst-case response the hard way. ## Zero, negative, non-numeric `limit=0` must have a defined meaning. Two reasonable choices: `400 Bad Request`, or "return no items, only metadata" — useful when paired with an opt-in total count so a client can ask "how many match?" without transferring rows. If it is undefined and the server returns an empty page, a client looping until it gets an empty page terminates immediately and silently processes nothing; if the server instead returns the default page, the client's intent is quietly ignored. Negative or non-numeric values are straightforward `400`s. The one thing to avoid is a stack trace or an unhandled parse exception surfacing as a `500` — parameter validation belongs at the edge, and an invalid client input is not a server error. ## Reporting the effective values Echo the applied page size back: `meta.page_size`, or make it visible in the `self` link. Combine it with an explicit end-of-data signal — `has_more: false`, or the absence of a `rel="next"` link — so clients never have to infer termination from "I got fewer items than I asked for". That inference is wrong under clamping, wrong when a filter reduces a page, and wrong for cursor-based APIs that may legitimately return a short page with more data behind it. ## Related contract details worth mentioning **Naming.** `limit` (with `offset`/cursor) or `size`/`per_page` (with `page`) — pick one pair and use it across the API. Mixed vocabularies across endpoints are a persistent client annoyance. **Interaction with cursors.** If the client supplies a cursor, the page size may change between requests without breaking anything (unlike sort or filters, which invalidate the cursor). It is fine to allow it; just be clear. **Cost per item is not constant.** If items can be expanded with sub-resources, the effective cap should account for it — a page of 100 orders each with expanded line items may be far more expensive than 100 bare orders. Some APIs lower the maximum when expansions are requested. ## The compact answer "Documented default around 20-50, never unbounded; a documented maximum derived from a payload and latency budget; above the max either clamp or 400, but say which; 0/negative/non-numeric is a 400; and echo the effective page size plus an explicit `has_more` so a clamped page isn't mistaken for the end of the collection."

  • Why is "fewer items than I requested" an unreliable end-of-collection signal?
    Because a short page has several other causes: the server clamped an oversized limit, post-query authorization filtering removed rows, or a cursor-based implementation returned a partial batch. The only reliable signals are the ones the server states explicitly — a `has_more` flag, or the presence or absence of a `rel="next"` link.
  • How would you choose the maximum page size for a specific endpoint?
    Work backwards from budgets: a target p99 latency and a maximum acceptable serialized response size, divided by the measured average item size for that resource. Lower it further when the endpoint supports expansions that multiply per-item cost. It should be a measured number per resource, not one global constant applied to a 5-field and a 100-field resource alike.

saying these in an interview costs you the question

  • Returning the entire collection when the size parameter is omitted
  • Honouring arbitrarily large limits because "the client asked for it"
  • Letting a non-numeric limit surface as a 500 instead of a 400
  • Clamping silently with no `page_size` or `has_more` in the response, so clients read a short page as the end
  • Using a single global maximum for every resource regardless of item size or expansions

context

open as a page

Compare paginating a REST collection with `page`/`size` (or `offset`/`limit`) parameters against paginating with an opaque cursor token such as Stripe's `starting_after` or Google's `pageToken`. What does each give up, and when would you choose each?

level: middleimportance: must knowfreq 72%

basics

~20 s

Offset paging is simple and supports jumping to any page, but gets slower the deeper you go and duplicates or skips rows when the collection changes between requests. Cursor paging anchors on the last item seen, so it stays cheap and stable, but only supports next/prev — no page numbers, no totals.

open as a page

You are specifying the cursor token for a public list API. What should the token encode, how long should it stay valid, and what should the endpoint do when a client sends a cursor whose anchor record has since been deleted, or sends it alongside a different sort or filter?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Encode the sort keys of the anchor row plus a fingerprint of the sort and filters, and sign it. Keep it valid as long as practical and document any expiry. A deleted anchor should still work if the token carries sort values rather than just an id. Mismatched sort or filters must be rejected with 400, not silently applied.

open as a page