Designing API Pagination

offset/limit vs cursor/keyset, page metadata, the Link header (rel=next/prev/first/last, RFC 8288), the cost of deep offsets, stable ordering, and total counts.

Pagination is how you serve a large collection in slices rather than all at once. Packing millions of rows into a single response would overwhelm server memory, bandwidth, and client processing, so list APIs almost always respond one page at a time. Which method you pick strongly affects performance, correctness, and usability.

The offset/limit approach

The most intuitive method specifies how many to skip (offset) and how many to take (limit). GET /items?offset=40&limit=20 means 'give me 20 items starting at the 41st.' You can jump straight to a page number, and it's easy to implement — hence its popularity early on.

GET /items?offset=40&limit=20 HTTP/1.1
Host: api.example.com

But it has two big weaknesses. First, deep-offset performance: given OFFSET 100000, the database must actually walk and discard the first 100,000 rows, so later pages get dramatically slower. Second, instability: if new items are inserted near the front while the user pages through, the offset shifts and the user sees the same item twice or skips one.

The cursor/keyset approach

Cursor (or keyset) pagination solves this. Instead of how many to skip, it anchors on 'everything after the last item you saw.' The server returns an opaque cursor pointing at the next position in each response, and the client passes it straight back.

GET /items?limit=20&after=eyJpZCI6MTA0Mn0 HTTP/1.1
Host: api.example.com

Announcing page relations with the Link header (RFC 8288)

The web-standard way (RFC 8288, Web Linking) is to carry navigation links in the Link header rather than the response body. The rel value names each link's relationship.

HTTP/1.1 200 OK
Link: <https://api.example.com/items?after=eyJpZCI6MTA2Mn0&limit=20>; rel="next",
      <https://api.example.com/items?before=eyJpZCI6MTA0Mn0&limit=20>; rel="prev",
      <https://api.example.com/items?limit=20>; rel="first"
Content-Type: application/json

rel="next", rel="prev", rel="first", and rel="last" point at the next, previous, first, and last pages. The client never has to assemble URLs itself — it just follows the links the server provides. With cursors, the last page isn't fixed because data keeps being appended, so rel="last" is often omitted.

Total counts and metadata — A total count ('how many in all') is handy for UIs but is not free. Computing an exact count over a large table costs nearly a full scan, canceling out the performance win of cursor pagination. So many large APIs skip an exact total entirely, return an estimated count, or offer it only via a separate request. Deliver page metadata (limit, the next cursor, and a has_more flag) consistently — either as a dedicated object in the body or via the Link header — and set a team convention so every list endpoint keeps the same shape.