A page number is not a cursor
Reconciliation and sync jobs page through lists that change while you walk them. Offset pagination counts rows you already saw; under concurrent writes those counts lie. A cursor — an opaque bookmark tied to a total sort order — is the contract that keeps a trailing-window Shopify pull from skipping today's order or reprinting yesterday's.
Reconciliation walks a list that keeps changing
At Al's Flowers the reconciliation job is not a nice-to-have. Webhooks miss deliveries. Workers die mid-batch. The shop needs a trailing-window pull that asks Shopify for recent orders and diffs them against what Mongo already has. The shape of that pull looks simple: page through orders until the window is exhausted, emit the missing ids, stop.
The failure mode shows up when the list is live. New orders land while you are paging. Old ones get cancelled or edited. If your pagination is "skip 50, take 50," the second page starts at a different place than it would have a second earlier. You skip a row that slid into the gap, or you process the same order twice and call it a sync bug.
That is not a rare edge case on a florist's busiest days. It is the normal behavior of offset pagination over a mutable collection. Cursor pagination exists because the position has to be a value in the sort order, not a count of rows you already saw.
The cursor is the contract, not a convenience
Shopify's GraphQL Admin API makes the contract explicit: you ask for first N, you get pageInfo with hasNextPage and endCursor, and the next request passes that cursor as after. REST does the same idea with a page_info token in the Link header. In both cases the client is not inventing page numbers. It is handing back an opaque bookmark the server minted.
Treat that bookmark as part of the API surface. Document that clients must round-trip the cursor unchanged. Document the maximum page size. Document that hasNextPage (or an empty next link) is the stop condition — not "we got fewer than N," which is true on a filtered page that still has more matching rows later, and false when the last page happens to be full.
When we expose list endpoints of our own — open tickets, recent invoices, field-app sync feeds — we copy that shape. Opaque cursor in, opaque nextCursor out, stable limit bounds. Partners should not need to know whether the underlying query is keyset on (createdAt, id) or something else. They need to know they can resume without inventing arithmetic.
Stable sort, unique tiebreaker, pinned filters
A cursor is only honest if the sort order is total. Sorting by createdAt alone is not enough when two orders share a timestamp; the database is free to return them in either order across requests, and page boundaries drift. Append a unique tiebreaker — usually the id — and build the cursor from that same compound key. The query becomes "rows after (createdAt, id)," not "rows after offset 100."
Filters belong in the contract too. If the first page was created_at:>=T and status:open, every continuation must mean the same filter. Pin those constraints server-side when you mint the cursor, or reject a continuation that tries to change them. A client that starts with one filter and pages with another is not continuing a walk; it is starting a different query with a stolen bookmark.
Index the compound sort you actually use. Without a matching composite index the keyset query degrades into a scan, and you have rebuilt the performance problem offset pagination already had — only with a longer token in the URL.
Opaque on purpose, verified on the way back
Do not hand clients raw database values as the cursor. Encode the payload (sort keys, tiebreaker, pinned filters) so the shape can change later without breaking callers. If the cursor carries anything a client could forge into a wider result set — a tenant boundary, a shop id, a tighter time window — sign it and verify the signature before decoding.
Verification failures should be a stable, client-visible error: invalid cursor, restart the query. Do not silently fall back to the first page. Silent restart looks like success and produces a partial sync that nobody notices until the missing ticket complaint arrives.
Rotation of the signing secret is the same pattern as any other HMAC secret: accept the previous secret for a window so in-flight pagination sessions do not break mid-walk, issue new cursors with the current secret, retire the old one after the longest expected session ends. Cursors are short-lived; a hard cut that forces a restart is usually preferable to accepting unsigned legacy tokens forever.
What this is not
This is not an argument against offset pagination for small, stable admin tables where a person clicks "page 7." Jump-to-page needs a position; cursors are for walks and syncs. Use the tool that matches the access pattern, and do not make a reconciliation job pretend it is a spreadsheet.
It is also not a substitute for idempotent writes on the rows you pull. Cursor pagination keeps you from skipping or double-fetching during the walk. Idempotency on the order id — already required for webhook ingest — keeps a double-fetch from becoming a double ticket. You still want both.
And it is not permission to drain a catch-up as fast as the API allows. Pagination correctness and rate-limit budgets are separate constraints. Walk with cursors so the set you intend to cover is the set you cover; throttle so live traffic still has room while you do.