Pagination (design pattern)
Pagination is a design pattern for dividing a large result set into discrete chunks, called pages, so that a client retrieves one chunk at a time rather than the whole set in a single response. Each page carries enough metadata for the client to request the next chunk, typically a page number, an offset, or a cursor.
The pattern addresses two pressures that grow with collection size. The first is resource cost: returning a million records in one shot is slow to compute, costly to transmit, and expensive to hold in memory on both ends. The second is usability: end users rarely want an unbounded list dumped in front of them. Pagination caps the cost of any single request and gives the consumer a predictable unit of work, whether that consumer is a person scrolling a feed or a service fetching rows from a database.
Where it is used
The same idea appears wherever a bounded unit of work is preferable to an unbounded one.
- HTTP APIs. Collection endpoints on HTTP APIs are
paginated by convention. A
GET /booksrequest returns a page of books rather than the entire catalog, with the page selected through query parameters such as?page=2&size=20or?cursor=eyJpZCI6MTAwfQ. REST APIs most often expose pagination through query parameters and response envelopes that include next-page links. - GraphQL. GraphQL standardises list pagination through
the connections specification popularised by Relay. A connection exposes
edges(each wrapping a node and its cursor),pageInfo(withhasNextPageandstartCursor/endCursor), and afirst/afterargument pair that requests N items after a given cursor. - Databases. Result sets are paginated at the query level with
LIMITandOFFSET, or with keyset predicates such asWHERE id > ? ORDER BY id LIMIT ?. The choice of strategy has a large effect on cost, described below. - User interfaces. Lists, tables, and feeds render one page at a time and fetch more as the user advances. Infinite scroll is a UI variant in which the next page is fetched automatically as the user nears the bottom, hiding the page boundary behind a continuous stream.
Strategies
Three strategies cover most implementations. They differ in how the position of the next page is expressed.
- Offset and limit. The client names a starting position (
offsetorpage) and a page size (limitorsize). The server skips the first offset rows and returns the next limit. This is the most intuitive strategy and the easiest to expose over HTTP query parameters. It also lets a client jump to an arbitrary page. - Cursor (keyset). The client holds an opaque cursor that encodes the position of the last item on the current page, eg. the row’s identifier or a hash of its sort key. The next request asks for items after that cursor. Because the cursor anchors to a value in the data rather than a positional offset, the result set is stable under concurrent inserts and deletes. This is the strategy favoured by GraphQL connections and by most large-scale APIs.
- Seek. A hybrid in which the client passes the sort value of the last row
on the previous page (eg.
WHERE created_at < ? ORDER BY created_at DESC LIMIT ?) rather than an opaque cursor. It achieves the stability of keyset pagination while keeping the predicate inspectable.
Trade-offs
Pagination is never free. Each strategy shifts cost somewhere.
- Offset is expensive on large datasets. Skipping the first 100,000 rows still requires the database to read and discard them, so deep pages degrade roughly linearly with the offset. For collections that grow without bound, cursor pagination is the better default.
- Offset is unstable under writes. If a row is inserted before the current offset between two requests, the next page shifts and an item is returned twice or skipped. Cursor pagination over a monotonic sort key avoids this.
- Cursors cannot jump. Because the cursor encodes a position in the data, a client cannot ask for "page 47" without walking there. Interfaces that require random access to pages, such as a numbered pager control, are a better fit for offset pagination.
- Ordering must be total. Cursor and seek pagination require a stable, unique sort key. Sorting on a non-unique column without a tiebreaker makes the boundary between pages ambiguous and can drop or duplicate rows.
- Total counts are costly. Reporting the total number of items (
"total": 12345) requires a separate count query, which on large or sharded datasets can be as expensive as the page itself. Many APIs omit the total or return only ahasNextPageflag.
Relationship to lazy loading
Pagination is closely related to lazy loading. Both defer work until it is needed, and both bound the cost of any single request. The distinction is granularity: lazy loading defers the materialisation of a single resource until it is accessed, while pagination splits a large collection into many smaller requests so that only the requested chunk is loaded. The two are often combined, eg. an infinite-scroll UI is a lazy loading of pages.
Caching a page is the natural complement. The first request for a page pays the database cost; subsequent requests for the same page can be served from a cache, keyed by the page parameters. Cursor pages cache well because their results are stable, while offset pages are invalidated by any insert or delete that lands before their offset.
See also
- Lazy loading
- Design patterns
- HTTP API
- Representational State Transfer (REST)
- GraphQL
- Caching
- Performance
- Latency
References
- GeeksforGeeks (2026). Pagination Design Pattern. GeeksforGeeks.