Skip to main content

Pagination

Pagination returns large collections in bounded pages. Offset/limit is simple and supports page jumps but slows on deep offsets; cursor/keyset pagination stays fast and stable at scale but lacks random access. Cap page size and use opaque cursors.

Type
API
When to Use
Large Result Sets, Limit Response Size, Infinite Scroll Feeds, Bound Query Cost

Pagination is the practice of returning a large collection in bounded chunks ("pages") rather than all at once. It protects servers, databases, and networks from huge responses and gives clients a predictable way to traverse data. Choosing the right pagination style is one of the most consequential API design decisions for any list endpoint.

How It Works

There are two dominant strategies. Offset/limit pagination uses ?offset=40&limit=20 (or page/size) to skip rows and take the next slice. It is simple and supports jumping to arbitrary pages, but it degrades on large offsets (the database still scans skipped rows) and can show duplicates or gaps when underlying data changes between requests.

Cursor (keyset) pagination returns an opaque cursor pointing to the last item; the next request asks for items after that cursor, typically translated into a WHERE id > :cursor ORDER BY id LIMIT n query. It stays fast at any depth because it uses an indexed seek instead of an offset scan, and it is stable under inserts and deletes. The trade-off is no random page access. GraphQL's Relay connection spec standardizes cursor pagination with edges, nodes, pageInfo, and endCursor.

When to Use It

Paginate any endpoint that can return an unbounded or large collection. Use offset pagination for small datasets and admin UIs that need page numbers. Use cursor pagination for large, frequently changing datasets, infinite-scroll feeds, and high-traffic APIs where deep offsets would be slow or inconsistent. Always cap limit to prevent abuse.

Trade-offs

Offset pagination is intuitive but slow and inconsistent at scale. Cursor pagination is fast and stable but cannot jump to page N and requires a stable sort key. Reporting total counts is cheap with offsets but expensive (sometimes impractical) with cursors. Exposing internal IDs in cursors leaks information, so cursors should be opaque and ideally signed.

Related Patterns

Pagination links are a natural place to apply HATEOAS, returning next/prev URLs so clients follow links rather than construct them. Caching (cache-aside) and materialized views help serve pages cheaply. API versioning governs how a paginated contract evolves. Moving from REST to GraphQL often adopts the Relay cursor connection model.

Example

GET /api/orders?limit=20&after=eyJpZCI6MTA0Mn0

200 OK
{
  "data": [ /* up to 20 orders after the cursor */ ],
  "page_info": {
    "has_next_page": true,
    "end_cursor": "eyJpZCI6MTA2Mn0"
  }
}

The client passes back end_cursor as after to fetch the next page via an indexed seek, staying fast no matter how deep it scrolls.