← Blog

Pagination, Part 1: The Model and Index-Based Paging

2026-09-07 · Marcel Claramunt ·
apisengineeringdata

A formal model of paginated APIs, page and offset paging as slices of an ordered sequence, and what breaks the moment the data moves under the walk.

Every API that returns a list eventually returns it in pieces. The pieces are easy; walking them correctly is not. In this series we'll treat pagination formally: define what the server holds, define what each request returns as a function of its parameters, and then ask what a client can actually guarantee when it stitches the pieces back together.

Part 1 sets up the model and covers the two index-based methods, page and offset. Part 2 covers cursors: opaque tokens and key-based cursors, and the boundary problem that comes with keys that are not unique. Part 3 covers range walks over time or ids, where the crucial and rarely documented fact is which end the server truncates from. Part 4 is best practices for both sides, API designers and client authors, with a table of what real venues do.

The model

The server holds a finite sequence, totally ordered by some key : a timestamp, an id, a block height.

A paginated endpoint is a function of its parameters that returns a contiguous slice of that sequence, at most rows long, plus some metadata:

The methods differ only in what is and what carries. That gives two independent questions a client has to answer for every endpoint:

  1. Where is the next slice? Expressed as an index, an offset, an opaque token, or a key value.
  2. When is the walk over? A published total, a page shorter than , an empty page, or an absent token.

Both answers are computed from the response to one request. Most pagination bugs are one of them being applied to the next request after the data has changed in between: the position has shifted, or the stop condition was decided on numbers that no longer hold.

Page-based

The client asks for page of size . Numbering pages from 0 keeps the arithmetic clean (most venues number from 1, which only shifts by one):

x1x2x3x4x5x6x7x8x9x10x11x12page 0page 1page 2f(i = 1, s = 4) = [x5, x6, x7, x8]
n = 12 rows, page size s = 4: the sequence is tiled into three slices, and page 1 (numbering from 0) is positions 5 to 8.

The server may also report a total, either as a count of items or as a count of pages . The two are not interchangeable: an item total needs to turn into a stop condition, and a caller who omits is using the server's default, which the client must know.

i ← 0
loop:
  rows, meta ← f(i, s)
  yield rows
  if rows is empty:                           stop   # always safe
  if meta.total is present:
    if counts pages and i + 1 ≥ meta.total:   stop
    if counts items and (i + 1)·s ≥ meta.total: stop
  else if |rows| < s:                         stop   # short page
  i ← i + 1

Two remarks. First, the empty-page test is the only one that is safe on its own: a short page is short relative to , so it needs to be known, and a total is a claim the server makes about at the moment of that request, which is a different moment from the previous page's. Second, a walk should never fail on a missing total. A page that omits it is a page you already have; carry on to the empty page.

Offset-based

Offset is page-based with the arithmetic done by the client:

so page is offset . The one real difference is how the walk advances. Advance by the rows you actually received, not by :

o ← 0
loop:
  rows, meta ← f(o, s)
  yield rows
  if rows is empty:  stop
  o ← o + |rows|
  (total and short-page tests as above)

Advancing by when the server returned fewer than rows, because its own cap is below your , skips rows silently. Advancing by is correct in both cases.

o = 6x1x2x3x4x5x6x7x8x9x10x11x12requested: o = 6, s = 4returned 3 rows (the server caps at 3)o + |rows| = 9o + s = 10x1x2x3x4x5x6x7x8x9x10x11x12x10 is skipped if the walk advances by s
Offset o = 6 asked for four rows and got three. The next offset has to be o + |rows| = 9, not o + s = 10, or x10 is never fetched.

When changes

So far was fixed, and both methods are trivially correct: the slices tile the sequence. Real history endpoints are not fixed; new fills, deposits and candles keep arriving during a walk. Consider the append-only case, new rows arriving after page was fetched.

If the server orders oldest first, the new rows land at positions , past everything the walk has touched. Page is exactly what it would have been. Append-only plus oldest-first is safe.

If the server orders newest first, which is what nearly every trade, order and transfer history endpoint does, every existing row shifts down by positions. Page now starts at what used to be position :

before: page 0 fetched (positions 1 to 4)x12x11x10x9x8x7x6x5x4x3x2x1page 0 = [x12, x11, x10, x9]after: two rows appended, page 1 fetched (positions 5 to 8)x14x13x12x11x10x9x8x7x6x5x4x3new, never visitedpage 1 = [x10, x9, x8, x7]x10 and x9 come back a second time
Newest-first ordering: m = 2 rows arrive between the two requests, every position shifts by two, and page 1 re-serves the last two rows of page 0. The two new rows are never visited.

The last rows of page come back again at the top of page . A deletion (a cancelled order disappearing from an open-orders list) does the opposite and skips rows. Under arbitrary insert, update and delete, neither method can promise anything. The guarantees a client can rely on:

fixed append, oldest first append, newest first arbitrary mutation
no duplicates yes yes no no
no gaps yes yes yes no

Two consequences follow. A page or offset walk over a live, newest-first endpoint has to deduplicate by a row identity (an id, or a key plus enough fields to be unique), because the walk itself cannot avoid re-serving rows. And a total that changes between two pages is not an anomaly worth aborting the walk over: it is the normal signature of the append case, and aborting on it buys no correctness, since the duplicates it implies are already in the rows the client holds. Use the total to stop, never to validate.

Closing

Index-based paging is a slice by position, and position is exactly what moves when the data moves. That is the whole weakness of the family, and the reason the next two methods address rows by what they are rather than where they are. Read Part 2 for tokens and key-based cursors.