# How to choose a pagination style for a list endpoint

> Compare the four ways to page a list on what decides it: whether a caller can miss a row when the data shifts, and what the query costs at page 900.

Source: https://voxgig.com/howto/choose-a-pagination-style-for-a-list-endpoint

- Audience: api-producer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## Short answer

Use keyset pagination, which asks for rows after the last one the caller saw, when the collection changes while callers read it. Use offset when the data is static or the caller needs to jump to an arbitrary page. Cursors are keyset with the position encoded opaquely, and Link headers are a way to advertise any of them.

---
## You will need

An endpoint returning a list, and a total order over it. Every style below depends on a sort that
never ties, which usually means the sort column plus the primary key as a tiebreaker. Without that,
two rows can swap places between one request and the next, and every style on this page inherits the
problem the choice was meant to solve.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Cursors](https://slack.engineering/evolving-api-pagination-at-slack/) | Public APIs where the position must stay yours to change | Callers cannot construct a position, so deep links and resumable jobs need you to keep them valid | An internal endpoint whose callers you control |
| [Keyset](https://use-the-index-luke.com/no-offset) | Feeds, exports and any collection that grows while it is read | No jumping to page 900, and the sort has to be part of the position | Callers need arbitrary page numbers |
| [Link headers](https://www.rfc-editor.org/rfc/rfc8288) | Any of the others, when clients should follow rather than build URLs | An extra header to parse, and clients that ignore it build URLs anyway | The response body already carries the next position |
| [Offset](https://www.postgresql.org/docs/current/queries-limit.html) | Static reports, internal dashboards, anything a person pages through | Rows shift under concurrent writes, and a deep offset scans everything it skips | The collection changes while callers read it |

Keyset and offset are the real choice, and the other two rows are about presentation. Cursors are
keyset with the position hidden, which buys you the freedom to change it later. Link headers say
where the next page is, whatever the position underneath is made of.

## Watch a row disappear

The failure is not theoretical and it does not need concurrency to reproduce. One insert between two
reads is enough.

```ts title="paginate.mjs"
/** Offset: skip N, take M. The skip is counted at the moment of the query. */
export function offsetPage(table, { limit, offset }) {
  const ordered = [...table.rows].sort((a, b) => a.id - b.id)
  return ordered.slice(offset, offset + limit)
}

/** Keyset: everything after the last id the caller saw, in the same order. */
export function keysetPage(table, { limit, afterId = 0 }) {
  const ordered = [...table.rows].sort((a, b) => a.id - b.id)
  return ordered.filter((r) => r.id > afterId).slice(0, limit)
}
```

The difference is what the second page is defined against. Offset counts from the start of the
collection as it is now. Keyset counts from a row the caller is holding, so it does not matter what
arrived in front of it.

```bash
node drift.mjs
```

```text output
offset
  page 1: 10, 20, 30
  page 2: 30, 40, 50
  rows the caller never saw: 60
keyset
  page 1: 10, 20, 30
  page 2: 40, 50, 60
  rows the caller never saw: none
```

Offset returned row 30 twice and lost row 60 entirely. A caller writing rows into a database as they
arrive gets a duplicate key error on 30, and nothing at all tells them about 60. A delete moves the
rows the other way, so a caller can skip a row without any insert happening.

## Count the cost at page 900

The second decision is what the query does at depth. An offset of 90,000 makes the database produce and
discard 90,000 rows before it reaches the ones you want. Page 900 is far slower than page 1 on the
same index. Keyset turns every page into the same indexed range scan, so page 900 costs what
page 1 costs.

That difference only matters if callers go deep. An internal dashboard nobody pages past screen three can
use offset forever. An export, a sync job or an agent walking a collection will go as deep as the
collection is long, and those are the callers who feel it.

Two published APIs show the two answers side by side.
[GitHub's REST API](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api)
pages with numbered pages and advertises the next one in a Link header, which suits a catalog people
browse. [Stripe's API](https://docs.stripe.com/api/pagination) pages with `starting_after`, an object
id the caller last saw, which is keyset with the position in plain sight. Neither is wrong. They are
answering different questions about what happens when the collection changes mid-walk.

## Check it worked

The property to assert is the one the opening paragraph describes: every row appears exactly once
across the pages a caller reads, even when the collection changes between requests.

```ts title="paginate.test.mjs"
test('an insert before the reader makes offset skip a row', () => {
  const table = seed()
  offsetPage(table, { limit: 3, offset: 0 })
  insertAtFront(table, { id: 5 })

  assert.deepEqual(offsetPage(table, { limit: 3, offset: 3 }).map((r) => r.id), [30, 40, 50])
})

test('keyset returns the same second page whatever was inserted', () => {
  const table = seed()
  const page1 = keysetPage(table, { limit: 3 })
  insertAtFront(table, { id: 5 })

  assert.deepEqual(keysetPage(table, { limit: 3, afterId: page1.at(-1).id }).map((r) => r.id), [40, 50, 60])
})
```

```bash
node --test paginate.test.mjs
```

```text output
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 108.247747
```

## When it goes wrong

The sort is not a total order. Two rows with the same `created_at` can come back in either order, so
a keyset position built on that column alone can skip one of them or repeat it. Sort on the column
and the primary key together, and put both in the position.

The second failure is a keyset position that outlives its query. A caller who pages with
`after_id=42` and then changes the sort gets a position that means something else, and the results
are silently wrong. Put the sort in the position and reject a request whose sort does not match it.

The third is a total count nobody can afford. Callers ask for one, and an exact count of a large
filtered collection is a full scan on every page. Offer an estimate, or a count only on the first
page, or a `has_more` boolean, which is what most callers were computing from the count anyway.

## When not to do this

Do not change the pagination style of a published endpoint in place. Existing callers have offsets
in their code and jobs half way through a run, so add the new style alongside and give them a
release to move.

Do not expose a raw database cursor or a row identifier that means something outside your system. A
position is an implementation detail, and once callers can read it they will construct it.

Do not let a caller page an unbounded collection with no ceiling on page size. The page size is the
part of the request that decides how much work one call can ask for.

Do not use offset for anything an agent walks. Automated callers page deep, retry on failure and
resume, and every one of those behaviors is where offset loses rows.

## Related how-tos

- [Advertise next and previous pages with Link headers](/howto/link-headers-for-pagination-rfc-8288)

- [Encode and sign opaque pagination cursors](/howto/encode-and-sign-opaque-pagination-cursors)

## Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed,
against an in-memory table rather than a database.