How to Design API Pagination for Small Services: A Practical Guide

designing api pagination small services

An endpoint that returns every matching record may work in a prototype, then slow down as the database and response grow. Pagination puts a bound on each request, but a weak design can still skip records, show duplicates, or become expensive on later pages.

For a small service, decide three things up front: how clients request the next slice, how the server orders records, and what happens when the collection changes between requests. This guide compares offset, cursor, and keyset pagination, then turns those choices into an API contract you can test.

Start with a clear response contract

A common response contains one bounded page of items and a way to ask for the next page:

{
  "data": [
    { "id": "ord_204", "createdAt": "2026-10-12T10:15:00Z", "total": 42.5 }
  ],
  "nextCursor": "opaque-token"
}

The field names are yours to choose. You could return a next URL, a nextPageToken, or a hasMore flag with a token. Document what indicates the end of the collection and make it unambiguous. For example, return nextCursor: null when there is no next page. Do not make clients guess based only on the number of items: a service may return fewer than the requested page size even when more results exist.

Set a documented default and maximum page size. Treat the size as a maximum, not a promise that every response contains exactly that many records. Decide whether an excessive size is clamped or rejected, and handle zero, negative, and malformed values consistently. Google’s AIP-158 pagination guidance is one detailed example of defaults, maximums, page tokens, and end-of-collection behavior; adapt a design to your own HTTP API rather than assuming every RPC convention applies unchanged.

If filters or sort order are supported, define whether clients must keep them unchanged while following a cursor. A token issued for status=paid should not silently continue a query for status=refunded. Bind continuation state to the relevant query, or validate and reject a token used with incompatible parameters.

Choose an approach for the collection

Approach How a client continues A good fit Main tradeoff
Offset or page number Requests a page number or number of rows to skip Small, mostly stable collections where users need to jump to a page Inserts and deletes can shift page boundaries; deep offsets can cost more
Cursor token Sends an opaque token returned by the previous response Public APIs that need simple forward or backward navigation The server must define what state the token represents and validate it
Keyset (seek) Continues after the last row's ordered values Large or frequently changing collections with indexed sort keys Efficient navigation, but jumping directly to an arbitrary page is difficult

These are related, not mutually exclusive. A public cursor token can contain or refer to keyset state. “Cursor” describes the continuation handle visible to a client; “keyset” describes how a database query seeks to the next ordered records.

Offset pagination: simple, with limits

An offset request might look like this:

GET /v1/orders?limit=25&offset=50

The server skips 50 matching rows and returns up to 25. This is straightforward to implement and makes page numbers easy to expose in an admin table.

The query still needs a deterministic, unique ordering:

SELECT id, created_at, total
FROM orders
WHERE account_id = $1 AND status = $2
ORDER BY created_at DESC, id DESC
LIMIT $3 OFFSET $4;

created_at alone may not be unique. Adding id as a tie-breaker ensures rows with the same timestamp still have a defined order. PostgreSQL’s LIMIT and OFFSET documentation warns that without a predictable ORDER BY, different limits and offsets can return inconsistent subsets. It also notes that skipped rows still have to be computed, so large offsets may be inefficient.

Offsets can shift while a person is paging. Suppose page one returns the newest 25 records. If a new record is inserted before the client requests offset 25, the next page can repeat the last record from page one. A deletion before the offset can instead make the client miss a record. A fixed unique sort order prevents arbitrary ordering, but it does not freeze a changing collection.

Use offsets when collections are small, page jumping matters, and occasional movement between pages is acceptable. For data that changes frequently, explain that pages are live views rather than a snapshot.

Cursor and keyset pagination: continue from ordered values

A keyset query asks for records after the last item in the previous response. With descending order on both fields, a PostgreSQL-style query can be:

SELECT id, created_at, total
FROM orders
WHERE account_id = $1
  AND status = $2
  AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT $5;

The cursor values $3 and $4 come from the last item actually returned to the client. The query uses a unique ordering tuple, so another row with the same timestamp is not accidentally left behind. SQL syntax for comparing ordered values varies by database; if you use nullable sort fields, define null ordering and continuation behavior explicitly or choose non-null cursor fields.

For a forward-only endpoint, fetch one extra row beyond the requested page size. If that extra row exists, there is another page; remove the extra row from the response and build the next cursor from the last row you did return. This avoids a separate count query just to decide whether to show “next.” If your storage or query cannot determine whether more results exist, make that limitation part of the response contract rather than inventing a total.

The client-facing token should be opaque and URL-safe. It can be a random reference to server-side state or a protected representation of the ordered values and query context; do not expose a parseable internal format as the API contract. Do not treat Base64 encoding as encryption, put credentials or sensitive data in a token, or use a token as authorization. Validate the token on each request and perform the normal authentication and access checks again. Google’s page-token guidance specifically calls out opacity and says tokens must not grant access to resources.

Cursor pagination usually avoids the shifting-window behavior of offsets when new records are inserted before the current position. It does not create a snapshot automatically: a record whose sort key changes can move across the cursor, and deleted records will no longer appear. If clients need a consistent export of a changing dataset, design a snapshot or export job explicitly instead of promising snapshot semantics from a cursor alone.

Stripe’s list API documentation is one example of an API that uses object IDs with starting_after and ending_before. The GitHub REST API instead gives clients navigable page URLs through a Link response header. The Web Linking standard, RFC 8288, defines the HTTP Link header format. A rel="next" link lets a client follow the server's chosen continuation URL without constructing page parameters itself:

Link: <https://api.example.com/v1/orders?cursor=opaque-token>; rel="next"

Clients should follow the supplied next link or token as documented, preserve required filters, and stop when the API signals the end. Avoid requiring clients to parse internal cursor contents.

Make the database query match the API

Pagination is a database access pattern, not just a response format. For a keyset query filtered by account and status and sorted by creation time and ID, a composite index beginning with the equality filters and then the ordered fields may help:

CREATE INDEX orders_account_status_created_id
ON orders (account_id, status, created_at DESC, id DESC);

The best index depends on the database, filter selectivity, query plan, and write workload. Check the actual execution plan with realistic data rather than adding an index because it resembles an example. Indexes improve some reads but use storage and add write work.

Keep the filter and ordering fields consistent across pages. If the endpoint allows arbitrary sorting, define which sort combinations you support and make each one deterministic with a unique tie-breaker. A token should represent the chosen sort and the last returned values, not just a page number disguised as a cursor.

Paginated list endpoints also need sensible request limits and abuse controls. Pagination bounds one response; it does not prevent a client from requesting every page in a tight loop. Pair it with authentication where appropriate, request timeouts, and rate limits. See our guide to API rate limiting for small services for a practical approach to request budgets.

Handle filtering, deletion, and concurrent changes

Before shipping, decide how continuation works in these cases:

  • A client changes a filter or sort order. Reject a mismatched token or start a new traversal; do not silently apply a token to a different query.
  • The cursor's record is deleted. Prefer storing the ordered values needed to continue, or server-side cursor state, instead of looking up an anchor row that may no longer exist.
  • New records arrive. State whether they appear only after a client starts a fresh traversal or can appear during the current one.
  • A sort value changes. If mutable fields determine order, a record can move across the cursor. Prefer immutable ordering keys when that fits the product.
  • The client repeats a request. Returning the same page for the same valid token is usually easier for clients to recover from than consuming a one-time token.
  • A token is malformed, expired, or belongs to another user. Return a documented client error and require the request to pass ordinary authorization checks.

For many small services, a live keyset traversal with immutable sort keys and a short-lived token is enough. An audit export or billing report may need a stronger, explicitly defined snapshot. Choose based on whether missing or newly added rows during a multi-request traversal would matter to the user.

Test pagination as a sequence

Test more than the first page. A focused test suite should cover:

  1. An empty collection, one page, and exactly one full page.
  2. A collection just over the page size and a final partial page.
  3. Multiple rows with identical primary sort values, confirming the tie-breaker returns each once.
  4. The same cursor with a changed filter, sort, account, or user.
  5. Inserts and deletes between page requests, confirming the documented live or snapshot behavior.
  6. Missing, malformed, expired, and tampered tokens.
  7. Default, maximum, zero, negative, and oversized page-size inputs.
  8. A client following next links until the API signals completion.

Integration tests should run against the same database engine as production when the query uses database-specific comparison behavior. Review plans for realistic result sizes, and test that authorization applies to every page—not only the first request. Our guide to API testing and documentation workflows for small teams covers how to keep these checks and API examples maintainable.

A practical default for a small service

Start with a documented default and maximum page size, a stable order with a unique tie-breaker, and a response that tells clients exactly how to continue. Use offset pagination for small collections where page jumping is useful and data movement is acceptable. Use a cursor backed by keyset queries for large or frequently changing collections that need efficient forward navigation.

Keep the token opaque, validate that it matches the query and caller, and do not imply that a sequence of live pages is a database snapshot. Most importantly, test multiple requests against ties and concurrent changes. A pagination design is reliable when clients can traverse it without guessing and your service can explain exactly what the results do—and do not—guarantee.

Official sources

Leave a Reply