# 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
- Updated: 2026-09-24

## 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.[^1]

## 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 learns of only one fault, through a duplicate key error on 30, since 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.[^2]

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.[^3]

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

[^1]: [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) is by Mark Nottingham, dated October
2017, and it obsoletes RFC 5988, listing its changes from that predecessor in an appendix. Its
introduction observes that HTML and Atom both had well-defined concepts of linking already, and
generalizes those into a model. Only then, in Section 3, does it define the header field, as one
more way to serialize a link. A `rel="next"` in a response header is the relation an HTML page
carries in a `<link>` element. The header exists so that a JSON response can say what a web page
always could.

[^2]: The database says so itself. The PostgreSQL
[manual](https://www.postgresql.org/docs/current/queries-limit.html) states that the rows skipped
by an OFFSET clause still have to be computed inside the server. It concludes that a large OFFSET
might be inefficient, which is the manual's way of saying page 900. Markus Winand's
[no-offset page](https://use-the-index-luke.com/no-offset), first posted in 2014, makes the same
point at greater length and traces it to the SQL standard. The rows are sorted first and then
dropped from the beginning, so the cost is in the design and not in any one implementation. The
page's title is a request for tool support for keyset pagination, and it has been making it since
2014.

[^3]: The two agree on one number. [GitHub](https://docs.github.com/en/rest/issues/issues) caps
`per_page` at 100, and [Stripe](https://docs.stripe.com/api/pagination) accepts a `limit` between
1 and 100, defaulting to 10. Neither document says why 100, and it is the same 100 whether the
position is a page number or an object id. At that ceiling, page 900 is row 90,000, which is where
the heading on this page got its figure. Stripe also returns `has_more`, a boolean that answers the
question most callers were asking the total count.