A customer’s nightly export is missing rows and their reconciliation does not balance. Nothing in your logs looks wrong: every page returned 200 and the row counts add up. The export walks a busy table by offset. A row deleted between page one and page two shifts everything after it back by one, so page two starts one row past where page one ended.
What you get
You will end up with an endpoint that accepts both pagination forms, a cursor every client can start using immediately, and evidence for why the move matters. This is for you if you publish a list endpoint over a table that is written to while people read it.
Short answer
Accept both forms on the same endpoint. Keep answering an offset parameter exactly as before, add
a cursor parameter, and return next_cursor on every response so clients adopt it without being
asked. Retire the offset form once usage says nobody sends it. The reason to move is that an offset
walk loses rows when the table is written to during the walk.
You will need
Node 22 or later, and a list endpoint with a stable sort key. The key has to be immutable and unique, which usually means the primary key or a creation time plus the key as a tiebreaker. The keyset pagination argument is also a performance one: an offset makes the database count rows it will discard.1 PostgreSQL row-wise comparison is the usual way to express the resulting query over a compound key.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A new endpoint version | The response shape changes as well, so the old one cannot express the new answer | Two endpoints to maintain, and a migration every client has to schedule | Only the pagination is changing |
| Accepting both parameters during a window | Almost every migration, because clients move on their own schedule | Two code paths and a retirement you have to drive to completion | The endpoint is new and has no clients |
| Breaking the offset form | An internal API with callers you can change in the same release | Every client breaks at once, including the ones you forgot | Anyone outside your team calls it |
| Keeping offset forever | Small, stable collections where the walk finishes before anything writes | Rows lost during concurrent writes, and a query that gets slower with depth | The table is written to while people page through it |
The migration shape is the easy part and the retirement is the hard part. Accepting both is a morning’s work. Getting the last customer off the offset form takes months, needs usage data per parameter, and needs somebody to own it. Start counting requests per parameter on the day you ship the cursor, because a retirement with no usage data is a guess.
Version the endpoint only if the response changes too. Pagination parameters are inputs, and adding an input that clients may ignore is additive by definition. A new version for an additive change buys a migration you did not need.
Accept both, and return the cursor to everyone
The cursor goes in every response, including the ones answering an offset request.
byCursor: (cursor, limit) => {
const all = sorted()
const start = cursor ? all.findIndex((r) => r.id > cursor) : 0
const page = start < 0 ? [] : all.slice(start, start + limit)
return {
data: page,
next_cursor: page.length === limit && page.length ? page[page.length - 1].id : null,
}
},
Returning next_cursor on offset responses is what makes the migration pull rather than push. A
client that reads it can switch by changing which parameter it sends, with no coordination, and no
announcement.
Refuse a request that sends both. There is no sensible interpretation, and picking one silently means a client that thought it had migrated is still walking by offset.
Measure the retirement, do not schedule it
Count requests per parameter, per client, and retire when the count is zero.
byOffset: (offset, limit) => ({
data: sorted().slice(offset, offset + limit),
next_offset: offset + limit < sorted().length ? offset + limit : null,
}),
Keep the offset path exactly as it was. A migration that also fixes a bug in the old path makes it impossible to say whether a client broke because of the deprecation or because of the fix.
Announce a sunset date once the usage is nearly zero, not before.2 A date announced while half your traffic still uses the parameter is a date you will move, and moving it teaches everyone that the next one is negotiable too.
Check it worked
Walk the same six rows three times, with a write between pages.
node demo.mjs
nothing changes during the walk
quiet table offset saw mtr_a,mtr_b,mtr_c,mtr_d,mtr_e,mtr_f 6 reads, 6 distinct
quiet table cursor saw mtr_a,mtr_b,mtr_c,mtr_d,mtr_e,mtr_f 6 reads, 6 distinct
a row is deleted from the front after page one
delete mtr_a offset saw mtr_a,mtr_b,mtr_d,mtr_e,mtr_f 5 reads, 5 distinct
delete mtr_a cursor saw mtr_a,mtr_b,mtr_c,mtr_d,mtr_e,mtr_f 6 reads, 6 distinct
The third line is the reconciliation failure from the opening paragraph, reproduced in nine rows of
code. One row was deleted and a different row, mtr_c, vanished from the results. The cursor walk
on line four saw all six. The insert case, further down the output, shows the mirror image: an
offset walk returns one row twice.
Neither failure raises an error, and that is what makes them expensive. Every page returned 200, the counts are plausible, and the only way to see the problem is to compare what a walk saw against what the table holds.
node --test collection.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 115.173191
When it goes wrong
Clients report duplicate rows after the change. The sort key is not unique, so rows with equal keys order differently between requests. Add a tiebreaker and include it in the cursor. A creation time on its own is never unique enough, and the day you find that out is the day two rows share a millisecond.
A cursor from last week returns nothing. The row it names was deleted. Decide whether that is an error or a restart from the nearest surviving key, and document which.
The cursor leaks an internal identifier. It is the primary key in plain sight. Encode it, so the shape stays yours to change.3
Offset traffic never reaches zero. One client cannot change, or nobody told them. Find out which and talk to them, because a deprecation with one holdout is a decision about that customer.
When not to do this
Do not migrate an endpoint whose callers only ever read the first page. A search result sorted by relevance is read once, and cursor pagination adds a concept for no benefit.
Do not offer a cursor over a sort the client chooses. A cursor encodes a position in one ordering, and a client that changes the sort halfway through needs a new walk rather than a translated cursor. Encode the sort in the cursor and refuse a mismatch.
Do not remove the offset parameter on the day the sunset arrives without checking the traffic again. Usage moves, and a customer who came back last week is a conversation rather than an outage.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The no-offset page is a request rather than a tutorial. Markus Winand posted it in August 2014, and its title asks for tool support. It quotes the SQL standard on offset as a sort followed by a drop, and closes by asking framework maintainers to build keyset support in. The page keeps a list of the frameworks that did, and its revision date runs to 2023. The database was never the obstacle. The pagination helpers were. ↩︎ Back to text
-
There is a header for the announcement. RFC 8594 defined
Sunsetin May 2019 as an Informational document, and RFC 9745 addedDeprecationon the Standards Track in March 2025, with one author on both. The two carry the same kind of fact in two date formats,Deprecation: @1688169599besideSunset: Sun, 30 Jun 2024 23:59:59 UTC. The second document explains the difference as historical, which is a standard’s word for a decision that predates it. ↩︎ Back to text -
Two large API publishers disagree on this. Stripe’s cursor is the object id itself,
starting_after=obj_fooin the documented form, in plain sight on every page. Google’s AIP-158 says page tokens must be opaque and not user-parseable, because if users can deconstruct them they will, and adds that base-64 on its own is not sufficient obfuscation. Both positions are in production at scale. One of them assumes the reader will look. ↩︎ Back to text