What it means
Cursor pagination, also called keyset pagination or the seek method, replaces the page number with a pointer. The server sends you a batch of rows and a token that encodes where that batch ended. You send the token back to get the next batch. You never tell the server "page 9"; you tell it "continue from here".
Almost every API starts with offset pagination instead, because ?page=3 is obvious and LIMIT 50 OFFSET 100 is one line of SQL. It works fine until the table is large or the data moves, at which point it fails in two ways that are both invisible from the client side.
Why offset breaks: the correctness argument
This is the failure that matters, and it has nothing to do with speed. Take a list sorted newest first, 50 rows per page.
- You fetch page 1 and get rows 1 to 50, where row 1 is the newest.
- While you are processing them, three new rows are inserted at the top.
- You fetch page 2 with
offset=50. What was row 48 is now row 51, so your second page starts by handing you three rows you already have.
Deletions do the same thing in the opposite direction. Delete three rows near the top and offset 50 now points past three rows you never saw. Nothing errors. Nothing logs. Your import job just silently missed three records, and the only way you find out is a reconciliation weeks later.
A cursor is immune to both because it is anchored to a row rather than to a count. "Everything after the row whose sort key is X" does not care how many rows were inserted above X, because none of them are after X.
Why offset breaks: the cost argument
OFFSET 40000 does not skip 40,000 rows. The database reads them, in order, and throws them away, and only then starts collecting the ones you asked for. The cost of page N grows linearly with N, so the deepest pages of a big list are the slowest, which is exactly backwards from what a user expects.
Keyset pagination turns that into an index seek:
- Offset:
ORDER BY created_at DESC, id DESC LIMIT 50 OFFSET 40000reads 40,050 rows to return 50. - Keyset:
WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 50seeks straight into the index and reads 50 rows. Page 800 costs what page 1 costs.
The row-comparison syntax matters. Writing it as created_at < :last_created_at OR (created_at = :last_created_at AND id < :last_id) is logically the same but frequently prevents the planner from using the composite index as a single seek. The tuple form is both shorter and faster.
The tiebreaker is not optional
A cursor is a position, and a position has to be unique. Sort by a timestamp alone and any group of rows sharing that timestamp becomes ambiguous: the page boundary lands somewhere inside the group, and the server has no way to know which members of it you already received. On a high-write table this is not a rare edge case, it is a nightly occurrence.
Always pair the sort column with something unique, normally the primary key, and compare the pair. If your ids are sequential you can often sort by the id alone. If they are UUIDv4 you cannot, because random UUIDs have no meaningful order; UUIDv7 and ULID were designed to fix exactly that by putting a timestamp in the high bits.
Opaque and signed
In the Pinlyx Data API, list endpoints take ?limit= (default 50, maximum 500) and ?cursor=, and every list response carries a meta.page block. The documented rule is short: follow meta.page.next_cursor until it is null, and treat the cursors as opaque and signed.
Those two adjectives are doing real work. A real next_cursor from the watch delivery listing is eyJrIjoiMTczNDU2IiwiZCI6ImEifQ, and yes, that base64-decodes to {"k":"173456","d":"a"}: a sort key and a direction. Decoding it is easy. Depending on it is a mistake, because the encoding is an implementation detail that can change without a version bump, and because the value is signed. A hand-edited cursor does not return adjacent rows, it gets rejected. That signature is what stops a cursor from becoming an accidental query parameter into somebody else's data.
Reading the page block
The meta.page object has four fields and each one has a trap attached:
limit: rows requested. Default 50, maximum 500. Ask for 501 and you get a 422, not a clamp.next_cursor: pass it as?cursor=next time. Null is the only reliable stop condition.count: rows in this page. A short page does not mean the last page.total: optional. Present only when counting is cheap. Code that assumes it is there will crash on the endpoints where it is not.
Some endpoints add a depth cap on top of pagination. The research findings feed stops at 500 rows per status, which is a product decision about how deep a feed is useful, not a bug you can page around.
Writing the loop correctly
The naive loop is four lines and has one dangerous failure mode: if a server bug or a filter interaction returns the same cursor twice, the loop never terminates and quietly burns your daily quota. Every production paging loop should carry three guards: a page cap, a check that the cursor actually changed, and a limit on the total rows collected. See the snippets below.
The other operational note is that paging costs requests, and requests are what the API meters. Walking 25,000 rows at limit=50 is 500 calls; at limit=500 it is 50. On the free tier, where the ceiling is 1,000 requests a day, that difference is the whole budget. Metering counts HTTP calls rather than rows, so page as wide as the endpoint allows.
Related concepts
- Rate limit: paging is a request multiplier, and the limit is per key rather than per loop.
- API scope: the permission that decides whether a list endpoint answers you at all.
- Exponential backoff: what to do when a page fails halfway through a walk.
- Webhook: the push alternative that removes the need to page for changes at all.
How Pinlyx handles it
Every list endpoint across the API's 127 operations uses the same two parameters and the same meta.page block, so a paging helper written once works everywhere. Cursors are opaque and signed, the maximum page is 500 rows, and next_cursor is a required field so there is always a stop condition to read rather than infer.