← All posts
7 min read

Pagination: Traversing Large Collections With Bounded Work

#technology#pagination#api#databases
📑 On this page

An API should not return every matching order, message, or product in one response.

As the collection grows, unbounded results consume memory, database time, bandwidth, and client rendering capacity.

Pagination divides a collection into bounded results and defines how a caller continues through a stable ordering.

The continuation method is part of the API contract.

A concrete example: a social feed

The client requests:

GET /feed?limit=20

The server returns:

{
  "items": [/* 20 posts */],
  "nextCursor": "eyJ0aW1lIjoi..."
}

The next request includes the cursor.

The cursor identifies where traversal should continue without asking the server to return millions of historical posts at once.

Bound every collection

Pagination protects:

  • Database query time
  • Server memory
  • Response size
  • Network bandwidth
  • Client memory
  • Rendering time

Even internal APIs need limits.

One trusted batch job can accidentally request every row and overload a shared database.

Define:

  • Default page size
  • Maximum page size
  • Ordering
  • Continuation behavior

Reject or cap excessive values consistently.

Offset pagination

Offset pagination uses:

LIMIT 20 OFFSET 40

or:

GET /orders?limit=20&offset=40

Benefits:

  • Simple
  • Supports jumping to a position
  • Familiar for page-number interfaces

Costs:

  • Deep offsets can be expensive.
  • Inserts and deletions can shift positions.
  • Callers may see duplicates or skip records.

It fits relatively small, stable collections and administrative interfaces where direct page navigation matters.

Page-number pagination

Page-number APIs expose:

GET /products?page=3&pageSize=20

Internally this usually becomes an offset.

Page numbers are user-friendly for catalogs and search results.

They suggest a stable finite list, which may not exist for rapidly changing feeds.

If sorting changes between requests, page 3 no longer refers to the same slice.

Document whether results are a live view or one snapshot.

Cursor pagination

A cursor is an opaque continuation token.

It may encode:

  • Last sort value
  • Last unique ID
  • Filters
  • Snapshot identifier
  • Direction

Clients should treat it as opaque and return it unchanged.

The server can change internal encoding without breaking clients.

Sign or encrypt cursors when tampering or data exposure matters.

Do not place sensitive fields visibly inside a base64 token and assume encoding is protection.

Keyset pagination

Keyset pagination uses the last seen ordered values:

SELECT *
FROM posts
WHERE (published_at, id) < (:lastTime, :lastId)
ORDER BY published_at DESC, id DESC
LIMIT 20;

This can use an index efficiently and remains stable when newer posts are inserted before the current position.

It does not naturally jump to arbitrary page 500.

Cursor pagination often wraps keyset values in an opaque token.

Stable ordering requires a tie-breaker

Ordering only by timestamp is not enough when several records share the same timestamp.

Use:

published_at DESC, id DESC

The unique ID creates deterministic order.

Every sort must have a stable tie-breaker.

Without one, records can move between pages even when data does not change.

The corresponding database index should match common filter and sort patterns.

Changing collections

During traversal:

  • New items arrive.
  • Old items are deleted.
  • Sort fields change.

Possible semantics:

  • Live traversal with possible movement
  • Snapshot fixed at first request
  • Best-effort feed traversal

Snapshot consistency provides repeatable results but requires storage or database support and can keep old versions alive.

A social feed may accept best effort. A financial export may require a fixed snapshot.

Choose based on use.

Duplicates and missing records

Offset example:

  1. Client reads items 1–20.
  2. New item is inserted at the front.
  3. Offset 20 now starts with the old item 20.

The client sees a duplicate.

Deletion can cause a skip.

Keyset continuation based on the last seen item avoids many shifts.

Client code should still deduplicate by stable ID for resilient user interfaces.

Filters and cursors

A cursor is valid only for the query context that created it.

If a caller changes:

  • Filter
  • Sort
  • Tenant
  • Search term
  • Page size policy

the old cursor may be invalid.

The cursor can encode or be bound to normalized query parameters.

Return a clear error for expired or mismatched cursors instead of silently producing unrelated results.

Forward and backward traversal

Some interfaces need:

  • Next page
  • Previous page
  • Newer messages
  • Older messages

Backward keyset pagination requires reversing comparison and result ordering carefully.

GraphQL connection patterns expose start and end cursors with page information.

Do not promise arbitrary bidirectional navigation unless the data model and indexes support it reliably.

For chat, "load older messages" may be more natural than numbered pages.

Total counts

Clients often ask for:

12,483 results

Exact counts can be expensive on large filtered datasets.

Options include:

  • Exact count
  • Approximate count
  • Count only on request
  • hasNextPage
  • No count

An infinite feed often needs only whether more results exist.

Do not perform an expensive exact count on every page if the user experience does not use it.

Document approximation when shown.

Fetch one extra item

To determine whether another page exists, the server can request:

pageSize + 1

Return the requested page size and use the extra record only to set:

{ "hasNextPage": true }

This avoids a separate count query.

The next cursor should derive from the last returned item, not the hidden extra item.

Small implementation details influence correct continuation.

Pagination and authorization

Apply authorization before pagination.

If the database retrieves 20 records and the application later removes 15 unauthorized ones, the client receives short pages and may infer hidden data.

Prefer query-level tenant and permission constraints where possible.

Cursors must not let users cross tenants or bypass filters.

Treat cursor input as untrusted and reapply all authorization on every request.

Search pagination

Search rankings can change as:

  • Index updates
  • Relevance scoring evolves
  • Personalization changes

Deep offset is often expensive.

Search systems may provide a search-after token containing score and document ID or a snapshot-like search context.

Do not expose raw internal score assumptions as a stable public contract unless necessary.

For long exports, a separate asynchronous export job may be more appropriate than thousands of interactive pages.

Client behavior

Clients should:

  • Honor maximum page size
  • Store next cursor
  • Stop when no continuation exists
  • Avoid parallel requests with the same cursor
  • Deduplicate by ID
  • Handle expired cursors
  • Cancel obsolete requests
  • Preserve loading and error state

Infinite scroll should not load forever without memory management.

Accessibility may favor explicit "Load more" controls and preserve focus and navigation.

Pagination is also a user-experience design.

Caching pages

Page-number URLs are cache-friendly for stable public catalogs.

Cursor pages can also be cached when:

  • Query is public
  • Cursor is deterministic
  • Data freshness policy permits

Personalized or authorized collections need private caching and correct identity keys.

Caching an initial page for too long can make a feed appear stale even while later pages are current.

Define freshness per use case.

Knowledge check

  1. Why can deep offset pagination become expensive?
  2. How does keyset pagination avoid shifts caused by new earlier items?
  3. Why does a stable sort need a unique tie-breaker?
  4. When is an exact total count unnecessary?
  5. Why must authorization be applied before pagination?

The one idea to remember

Pagination is a contract for bounded, ordered traversal. Choose offset for simple stable lists and cursor or keyset methods for changing large datasets, then define ordering, consistency, authorization, and continuation explicitly.