jsonapirestpagination

Pagination Patterns for JSON APIs: Offset, Cursor & More

·9 min read·APIs

Why pagination matters

Returning an entire table as one giant JSON array is slow, memory-hungry, and fragile. Pagination splits results into pages so responses stay small and predictable. The pattern you choose affects performance, correctness, and how clients navigate.

Offset pagination

The classic approach: ?page=2&per_page=20 (or ?limit=20&offset=20).

json
{
  "data": [ { "id": "A21" }, { "id": "A22" } ],
  "pagination": { "page": 2, "per_page": 20, "total": 1280, "total_pages": 64 }
}

Pros: simple, lets users jump to any page, easy to show "Page 2 of 64." Cons: slow on large tables (the database still scans skipped rows), and unstable — if a row is inserted while the user paginates, items shift and they see duplicates or skips.

Cursor pagination

Instead of a page number, return an opaque cursor pointing at the last item. The client passes it back to get the next page:

json
{
  "data": [ { "id": "A21" }, { "id": "A22" } ],
  "pagination": {
    "next_cursor": "eyJpZCI6IkEyMiJ9",
    "has_next": true
  }
}
text
GET /orders?limit=20&cursor=eyJpZCI6IkEyMiJ9

Pros: stable under inserts/deletes, fast even on huge datasets, ideal for infinite scroll. Cons: no "jump to page N," and you cannot show a total page count cheaply.

Keyset pagination

A concrete form of cursor pagination that uses a real sortable column instead of an opaque token:

text
GET /orders?limit=20&created_before=2026-05-24T08:00:00Z

The query becomes WHERE created_at < ? ORDER BY created_at DESC LIMIT 20, which uses an index efficiently. Encode the key into a cursor so clients treat it as opaque.

Comparison

PatternJump to pageStablePerformance at scaleBest for
OffsetYesNoDegradesSmall datasets, admin tables
CursorNoYesExcellentFeeds, infinite scroll, large data
KeysetNoYesExcellentTime-ordered data

Response envelope tips

  • Always include navigation info (next_cursor / has_next, or page/total_pages).
  • Consider HATEOAS-style links: "links": { "next": "/orders?cursor=..." } so clients do not build URLs by hand.
  • Set a sane default and maximum limit (for example default 20, max 100) to protect your backend.
  • Keep cursors opaque — Base64-encode the key so clients do not depend on its internal format.

Frequently asked questions

Cursor (or keyset) for anything that grows large or updates frequently. Offset is fine for small, mostly-static admin lists where "jump to page" matters.

You usually do not — counting is expensive on large tables. Expose has_next instead, or provide an approximate count if the UI truly needs one.

Classic offset instability: rows were inserted or deleted between page requests. Switch to cursor/keyset pagination to fix it.

Try JSON Formatter

Format paginated API responses to inspect their envelope structure.