Offset pagination and its hidden drift

Offset-limit pagination asks the server for a starting position and a count. The server returns items starting after that many records in the current sort order. When a record is removed before the current offset, every later page effectively moves one position earlier. The client therefore misses the record that slid into the gap. When a record is added, the client receives the same record twice because its position has shifted relative to the original offset.

The problem compounds with concurrent clients or long-lived pagination sessions. Nothing in the protocol tells the client that the collection snapshot has changed, so the only visible symptom is missing or repeated data.

Cursor pagination removes positional drift

Cursor-based pagination replaces the numeric offset with an opaque token that encodes the position of a specific record in a stable order. The token is usually the value of the sort key or a combination of keys that together identify a unique boundary. When the client supplies the token in a page[after] or page[before] parameter, the server returns only records that fall strictly after or before that boundary.

Because the token points to a concrete record rather than a count, deletions or insertions before the cursor do not change which records come after it. The same token continues to produce the same subsequent page even if the collection size changes.

  • page[size] controls the maximum number of records returned.
  • page[after] returns the page immediately following the cursor.
  • page[before] returns the page immediately preceding the cursor.
  • An invalid cursor must produce a standard error rather than silent wrong results.

Stable ordering is mandatory

Cursor pagination only works when the server can guarantee a total order. If the client requests sorting by a non-unique field such as age, multiple records share the same value and their relative order is undefined. The server must therefore append a unique tie-breaker, typically the primary key, so that every record has a distinct position in the sequence.

When no client sort is supplied, the server must still impose a deterministic order before it can return paginated results. Without that guarantee, the same cursor can point to different records on successive calls.

Termination and empty-page conditions

A client knows it has reached the end when the server returns an empty array for a page[after] request. The same rule applies in reverse for page[before]. When both page[after] and page[before] are supplied and the window between them exceeds the requested size, the server returns the after page and sets a rangeTruncated flag so the client understands that not all requested records were delivered.

Servers that encode session or snapshot identifiers inside the cursor can freeze the result set for the lifetime of that cursor. Most implementations accept that the list may change and simply ensure that no record is skipped or duplicated relative to the cursors that are actually exchanged.

Practical checklist for implementers

Choose cursor pagination for any collection that clients are likely to iterate in full. Reserve offset pagination only for cases where clients must jump to an arbitrary page number and the collection is known to be static during the session.

Always include a unique tie-breaker in the sort order. Document the exact fields that constitute the cursor so clients understand what stability they can expect.

Return pagination links or cursors in the response body so clients do not need to construct tokens themselves. Treat the cursor as an opaque string; never document its internal format.