API pagination seems simple until you’re paginating through data that’s actively changing — the two dominant strategies handle that differently, with real correctness implications.
# Offset-based: simple, but breaks under concurrent inserts GET /api/products?offset=20&limit=10 # If a new product is inserted at position 5 while paginating, # every subsequent page shifts by one - a client can see a duplicate # OR silently skip an item entirely # Cursor-based: stable regardless of concurrent changes GET /api/products?after=eyJpZCI6MTIzfQ&limit=10 # The cursor encodes a specific position (e.g. the last seen ID), # not a numeric offset - immune to items being inserted/deleted elsewhere
Why Cursor-Based Wins for Active Data
Offset pagination computes “skip the first N rows” fresh on every request — if the underlying data shifts between page 1 and page 2 requests, the offset no longer points at a consistent logical position. Cursor-based pagination instead anchors to a specific row (typically via its ID or a composite sort key), remaining stable regardless of what else changes in the table.
The Trade-off
Cursor-based pagination can’t easily jump to an arbitrary page number (“show me page 47”) the way offset pagination can — you can only move forward or backward from a known cursor. For infinite-scroll style interfaces this limitation rarely matters; for a paginated admin table with page-number navigation, it’s a genuine trade-off worth considering.