pagination

why

  • any read whose result set can grow will be asked for more rows than a screen or request can afford.
  • bounded cursor page = one screen load = one durable query with stable known cost.
  • cursor makes "the same logical page" re-requestable while rows are inserted.
  • offset fails both ways: cost grows with the offset; a shifting table silently drops or duplicates items for the user.

rule

  • any read whose result set can grow gets a bounded page contract before it ships.
  • page = cursor over a stable, server-defined order. one page is one durable query with a known cost, not an unbounded scan whose cost grows with the table.
  • offset skips rows to reach a page (50,000 rows for page 1000) — cost grows linearly with the offset.
  • a row inserted between a user's page request and load call shifts the whole result set: missed items or duplicates.
  • without a cursor the client cannot re-request the same logical page; it can only guess an offset and hope the table hasn't changed.

contract shape

fieldrule
cursoropaque, typed, stable across consecutive reads of the same order.
orderserver-defined and named in the contract (createdAt desc); the client never supplies raw order SQL.
page sizebounded above by the contract; the client may request smaller.
next cursorpresent iff another page exists. never hasMore + offset.
  • cursor survives inserts and deletes between pages: it encodes the position in the stable order, so a row inserted before the cursor cannot duplicate into the next page.
  • offset (skip/take against a growing table) duplicates and skips rows; its cost grows with offset — reject it for durable lists.

decision table

read kindpagination decision
durable list that can growcursor over a stable indexed order.
fixed small set (enum-like, settings)no pagination; one bounded read.
search/filter resultscursor plus typed filter contract; filters are part of the cursor identity.
workflow/step historycursor over the durable record's time order.
aggregate/dashboard numbersnot a list; one projection read, per aggregate design.

do not

  • ship an unbounded list operation because "there are only a few now".
  • accept client-supplied raw ordering or unbounded limit.
  • return hasMore: true without the cursor that continues the order.
  • let the client reconstruct the cursor format — it is opaque server vocabulary.

verification

  • insert rows around the cursor position between two page reads; confirm no duplicate and no skipped row.
  • request the largest allowed page; confirm the boundary rejects larger.
  • read a filtered list, mutate a matching row's filter field, continue the cursor — the continuation follows the contract's declared snapshot semantics.

good and bad

bad

list(offset: number, take = 50) // skip/take against a growing table
// response: { items, hasMore: true }
  • duplicates and skips rows when anything is inserted between reads.
  • cost grows with offset; hasMore gives the client no way to continue the order.
  • user-visible cost: a list that silently loses or repeats items.
  • operational cost: query latency climbs with table growth until the endpoint times out under normal traffic.
  • paging then becomes an incident-driven rewrite instead of a contract decided up front.

good

list(cursor?: OrderCursor, pageSize?: number)
// order fixed in contract: createdAt desc; response: { items, nextCursor? }
  • typed opaque cursor over a stable server-defined order.
  • no duplicates across inserts; cost bounded by page size; continuation is explicit.
  • every screen load stays one indexed query with a cost the contract already declared — the list keeps working as the table grows.
  • the same page contract later serves infinite scroll, export, and retry without a redesign.

search pages

go to any page