Pagination & Error Modeling
Use opaque cursor/keyset pagination for O(1) stable paging, and RFC 9457 structured errors with precise status codes so clients retry 5xx/429 but never other 4xx.
Two boring details where APIs fall over at scale
Two boring-looking API details, pagination and error shape, are where APIs quietly fall over at scale. Both have a correct answer.
Pagination: offset is the trap
The naive approach is offset/limit: ?offset=100000&limit=20. Two problems. It is O(n) deep: to
return page 5,000 the database must scan and discard the first 100,000 rows, so deep pages get
linearly slower and hammer the DB. And it is unstable under inserts: if a new row is added at the top
between fetching page 1 and page 2, every item shifts down by one, so the user sees a duplicate or
skips a row. On a live feed this is constant.
The fix is cursor (keyset) pagination. Instead of "skip N rows," you say "give me rows after this position." With an indexed ordering column:
WHERE (created_at, id) < (:cursor_ts, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20
Because the DB seeks directly into the index rather than counting from the start, each page is O(1)
regardless of depth, and because the cursor points at a stable row identity, inserts at the top do
not shift the window. You return an opaque next_cursor (base64 of the last row's sort key) so the
client cannot fabricate positions and you can change the encoding later. Always enforce a server-side
max page size, and prefer a has_more boolean over an exact total count, because COUNT(*) on a
large table is itself an expensive scan.
Errors: machine-readable and precisely coded
Clients need machine-readable, consistent errors to retry correctly, and humans need enough detail to
debug. The standard shape is RFC 9457 Problem Details (the successor to RFC 7807): a JSON body with
type (a URI naming the error class), title, status, detail, and instance, plus a
correlation id so a support ticket maps to a specific log line.
Status codes must be used precisely, because they drive client retry logic:
400malformed request,422well-formed but semantically invalid (validation).401not authenticated,403authenticated but not allowed,404not found.409conflict (for example a version clash or duplicate),429rate limited.5xxserver error.
The critical distinction is retryable versus not. 5xx and 429 are retryable (with backoff,
and honor Retry-After on 429). 4xx other than 429 are the client's fault and must not
be blindly retried, because retrying a 400 just wastes calls and can amplify load.
Interview nuance: two things separate strong answers. Saying "keyset pagination is O(1) and stable, offset is O(n) and shifts under inserts" (with the SQL), and saying "structured errors so clients can distinguish retryable 5xx/429 from non-retryable 4xx," plus the warning to never leak stack traces to clients.
Recap: use opaque cursor/keyset pagination with a bounded page size for O(1) stable paging, and return RFC 9457 structured errors with precise status codes so clients retry 5xx/429 but not other 4xx.
Sort each API habit by how it behaves once the table is large and the traffic is real.
Apply
Your turn
The task this lesson builds to.
Design a feed/list endpoint that stays fast at page 10,000 and is stable while new items are inserted, and define the error response shape for validation, auth, conflict, rate-limit, and server errors.
Think about
- Why does offset pagination degrade and become unstable under inserts?
- What does a cursor/keyset page look like, and why is it O(1)?
- What structured error body and status codes let clients retry correctly?
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Design pagination and error handling for the Twitter/X home timeline at 500M tweets per day, where users scroll infinitely, new tweets stream in constantly, and the timeline is ranked (not strictly chronological). Explain how the cursor survives ranking and inserts, and how you keep p99 fast at deep scroll.
Think about
- What does the cursor point into when the feed is ranked rather than time-ordered?
- Where do new tweets go so they do not disrupt a user's downward scroll?
- What store serves page 500 in O(1) instead of re-querying the tweet database?