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

Source: https://voxgig.com/howto/migrate-offset-to-cursor-pagination

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://use-the-index-luke.com/no-offset) argument is also a performance one: an
offset makes the database count rows it will discard.[^1] [PostgreSQL row-wise
comparison](https://www.postgresql.org/docs/current/functions-comparisons.html#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](https://cloud.google.com/apis/design/versioning) | 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](https://docs.stripe.com/api/pagination) | 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](https://www.rfc-editor.org/rfc/rfc9745.html) | 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](https://use-the-index-luke.com/no-offset) | 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.

```js title="collection.mjs"
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.

```js title="collection.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test collection.test.mjs
```

```text output
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

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Loop over every page of a REST collection](/howto/loop-over-every-page-of-a-collection)

## Last verified

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

[^1]: The [no-offset page](https://use-the-index-luke.com/no-offset) 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.

[^2]: There is a header for the announcement. [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)
defined `Sunset` in May 2019 as an Informational document, and [RFC
9745](https://www.rfc-editor.org/rfc/rfc9745#section-4) 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.

[^3]: Two large API publishers disagree on this. [Stripe's](https://docs.stripe.com/api/pagination)
cursor is the object id itself, `starting_after=obj_foo` in the documented form, in plain sight on
every page. Google's [AIP-158](https://google.aip.dev/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.