Skip to content

Pagination

Pagination returns a collection in slices. There are two common strategies, and the right choice depends on how big and how dynamic your data is.

The client asks for a page by position: ?page=3&limit=20 (or ?offset=40&limit=20). Simple, allows jumping to any page, and shows total counts easily.

GET /articles?page=2&limit=20

Its weakness: deep offsets are slow (the database must skip all preceding rows), and inserts shift the window — if a row is added while you page, you can see duplicates or skip items.

The client passes an opaque cursor marking where the last page ended: ?limit=20&cursor=eyJpZCI6MTQwfQ. The server returns the next slice plus the cursor for the page after.

flowchart LR
  A[GET /articles?limit=20] --> B[20 items + nextCursor]
  B --> C[GET /articles?limit=20&cursor=...]
  C --> D[next 20 + nextCursor]
Each page hands back the cursor for the one after it

Cursors are stable under inserts and fast at any depth, but you cannot jump to an arbitrary page and exact totals are harder. They are the better default for large or frequently-changing collections (feeds, logs, search).

A page should be wrapped in an envelope with data plus paging metadata and links:

JavaScript
Pagination StyleAdvantagesDisadvantagesFits
Offset (?page=2&limit=20)Simple, random accessUnstable when data changesAdmin tables, fixed datasets
Cursor (?after=cursor123)Stable, good performance on large datasetsNo total count, can’t jump to a pageFeeds, infinite scroll
Keyset (?after_id=42)Fast, stableNeeds a unique sortable keyLarge sorted collections

Offset pagination for a real-time feed Symptom:

  • A feed where data is added often — page 2 shifts to page 3 as new items arrive
  • Users see duplicate items or missing items while paginating
  • Use cursor pagination for real-time data

No pagination default Symptom:

  • GET /users returns every record with no limit
  • Database and memory blow up once users number in the millions
  • Always set a default limit (limit=20) and a max limit (limit=100)

💡 Real-world examples

GitHub API:

  • Cursor-style pagination via the Link header: <https://api.github.com/repos?page=2>; rel="next"
  • Max 100 items per page (the default varies by endpoint — check that endpoint’s reference)

Stripe API:

  • Cursor pagination: GET /charges?starting_after=ch_xxx&limit=20
  • has_more: true tells the client there’s more data
  • Cursor-based because charges keep growing over time

Sources: GitHub REST API pagination, Stripe API — pagination

What is the main weakness of offset pagination on large, changing data?
What does a cursor represent?
Which strategy is the better default for an infinite feed?