How-to › Move data in and out

How to migrate an API from offset to cursor pagination#

Move a list endpoint from offset to cursor pagination without breaking the clients still sending an offset, and show why the change is worth making.

Audience
API producer
Level
intermediate
Topic
Paginate collections
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A new endpoint versionThe response shape changes as well, so the old one cannot express the new answerTwo endpoints to maintain, and a migration every client has to scheduleOnly the pagination is changing
Accepting both parameters during a windowAlmost every migration, because clients move on their own scheduleTwo code paths and a retirement you have to drive to completionThe endpoint is new and has no clients
Breaking the offset formAn internal API with callers you can change in the same releaseEvery client breaks at once, including the ones you forgotAnyone outside your team calls it
Keeping offset foreverSmall, stable collections where the walk finishes before anything writesRows lost during concurrent writes, and a query that gets slower with depthThe 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.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. 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

  2. There is a header for the announcement. RFC 8594 defined Sunset in May 2019 as an Informational document, and RFC 9745 added Deprecation on the Standards Track in March 2025, with one author on both. The two carry the same kind of fact in two date formats, Deprecation: @1688169599 beside Sunset: 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

  3. Two large API publishers disagree on this. Stripe’s cursor is the object id itself, starting_after=obj_foo in 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.