Cursor pagination and offset pagination both split a long result set into smaller responses. They behave differently when records are inserted, deleted or reordered while a client is moving through the list. Offset pagination asks for positions such as “skip 100, return 25.” Cursor pagination asks the API to continue after a boundary the API previously returned. That boundary is the important part.

Cursor vs offset pagination at a glance

Question Offset pagination Cursor pagination
Typical request page=5 or offset=100&limit=25 after=opaque_cursor&first=25
Client state Page number or numeric offset Cursor returned by the API
Random access Usually straightforward Often limited to forward or backward traversal
Insert before the current page Can shift later offsets Can preserve continuation after the boundary
Delete before the current page Can pull later records into an earlier position Can preserve continuation if the boundary remains valid
Meaning of the token A position the client understands An opaque continuation point defined by the server
Useful for Stable lists, back-office tables, shallow browsing Changing feeds, large collections, continuous sync

Cursor pagination is not automatically correct. Its behavior depends on the ordering key, tie-breaker, cursor lifetime and consistency model. Offset pagination is not automatically wrong. It can be the clearest interface when the list is stable and users need page numbers.

How offset pagination moves

Imagine a list sorted newest first:

  1. record A
  2. record B
  3. record C
  4. record D
  5. record E
  6. record F

A client asks for two records per page. Page one returns A and B. Before the client asks for page two, a new record X is inserted at the top. The list now begins X, A, B, C.

If page two means “skip the first two rows,” it returns B and C. B appears twice across the client’s two responses. A deletion above the boundary can create the opposite problem: a record moves into the skipped range and is never seen.

The database is not confused. The numeric positions changed between requests. Offset pagination describes where to start in each current result set, not which records the client already received.

This matters most when all three conditions are true:

  • the sort order changes frequently;
  • the client takes long enough for changes to happen between pages;
  • the client expects one traversal to include each record once.

If the client is simply rendering a directory where occasional movement is acceptable, page numbers may still be the better user interface.

What a cursor represents

A cursor is a token that tells the server where to continue. GraphQL’s pagination guidance recommends treating cursors as opaque. A client stores the cursor but does not parse it or build a new one. The server can encode an item ID, a sort value, a compound boundary or an internal continuation token.

A useful cursor follows the ordering rule. For a list ordered by created_at DESC, id DESC, the boundary may conceptually contain both values from the last record. The next query then asks for rows after that pair, not rows after a numeric position.

The ID is a tie-breaker. Timestamps alone can repeat. Without a stable secondary key, the API cannot unambiguously place two records created at the same instant.

Stripe’s v1 list APIs show a concrete ID-based model. Their starting_after and ending_before parameters accept an existing object ID. Results are returned in reverse chronological order, and the list response states whether more items remain. Stripe’s documentation also warns that its v2 APIs use a different pagination interface. The client has to follow the contract of the endpoint it is calling rather than assuming one site-wide shape.

Cursor does not mean snapshot

A cursor usually marks a boundary in an ordered collection. It does not necessarily freeze the entire collection at the time of the first request.

A new record inserted before the cursor may not disturb forward traversal, because the next query starts after the previous boundary. A record inserted after the cursor can legitimately appear on a later page. Updates that change the sort key are harder: a record can move from one side of the boundary to the other while the traversal is in progress.

If one traversal must reflect a single point in time, the API needs more than an ordinary cursor. Options include:

  • a snapshot or read timestamp carried across every page;
  • a versioned change feed;
  • a server-side export job;
  • a transaction or database snapshot with a documented lifetime;
  • a cutoff such as “created before this fixed instant,” combined with a stable tie-breaker.

The cursor and the snapshot solve different problems. The cursor says where to continue. The snapshot says which version of the collection the client is traversing.

Opaque cursors protect the contract

GraphQL’s current guidance notes that an opaque cursor allows the server to change its internal pagination model later. A cursor might initially encode an offset and later move to a compound key without forcing clients to construct a different request.

Opaque does not mean secret. Base64 is an encoding, not encryption. A cursor can still reveal information if the server places raw identifiers or timestamps inside it. If that matters, the server can sign, encrypt or replace those values with a server-side token.

The API should document behavior without documenting the internal format:

  • whether cursors expire;
  • whether they are scoped to one user, query or filter set;
  • whether changing the sort or filter invalidates them;
  • whether clients may store them for later resumption;
  • what error appears when a cursor is no longer valid.

Clients should keep the filter and sort parameters that produced the cursor. Reusing a cursor with a different query can produce an error or a misleading continuation point.

Not every API exposes pagination as a field named cursor. GitHub’s REST API uses a Link response header that can contain URLs for the next, previous, first and last pages. GitHub advises clients to use those returned URLs. Depending on the endpoint, the link can carry page, before/after or since parameters.

Following the returned next link is safer than reconstructing it. The server may preserve endpoint-specific parameters, enforce a maximum page size or change the pagination mechanism. GitHub notes that most endpoints cap per_page at 100 and silently reduce a larger request to the maximum. A client that assumes it received the requested count can stop too early.

The termination rule should come from the API too:

  • a has_more boolean;
  • pageInfo.hasNextPage;
  • the presence of a next link;
  • an empty page, if the contract says so;
  • a continuation token that becomes absent.

“Received fewer than requested” is only safe when the endpoint explicitly defines that as the end.

Duplicates still need a policy

Cursor pagination reduces movement caused by inserts before the boundary. It does not remove every source of duplicates.

Duplicates can appear when:

  • a record’s sort key changes during traversal;
  • the client retries a page after processing some but not all items;
  • the API returns overlapping pages by design;
  • the client restarts from an older saved cursor;
  • the list joins data whose ordering is not stable.

For a synchronization job, store a durable record identity or version and make processing safe to repeat. Advance the saved cursor only after the page’s required work is durable. If the worker crashes halfway through a page, repeating the page is safer than skipping the uncommitted records.

This is the same failure boundary described in our guide to idempotency keys: transport progress and business progress are related, but they are not the same record.

Deletions and missing cursor records

An ID-based cursor may point to a record that is deleted before the next request. An API can handle that in several ways:

  • encode the sort boundary so the record itself is not needed;
  • retain tombstones long enough for active cursors;
  • reject the cursor and require the client to restart;
  • use a server-side continuation token;
  • define that cursors are short-lived and best-effort.

The client should not guess. A 404, validation error or empty page may have different meanings across APIs. The contract should say whether a missing boundary is recoverable and whether restarting risks duplicates.

Backward pagination needs its own tests

Forward pagination is not proof that backward navigation works. A bidirectional API must define how before, after, first and last interact with the sort order.

GraphQL’s connection model places cursors on edges and exposes startCursor, endCursor, hasNextPage and related page information. That structure helps, but the server still has to preserve one consistent order. Reversing the database query for a backward page and forgetting to reverse the returned records is a common way to make the order jump.

Test at least:

  • the first and last page;
  • a page with fewer than the requested number of records;
  • equal primary sort values;
  • an insert before and after the boundary;
  • a deletion of the boundary record;
  • an update that changes the sort key;
  • retrying the same cursor;
  • changing filters while reusing an old cursor.

When offset is the clearer choice

Offset pagination works well for many products. An internal admin table may need “page 12” and a rough total count more than it needs a stable continuous scan. A small catalog sorted by an immutable key may barely move. A data warehouse query may run against a fixed snapshot.

Offset is also easy to inspect in logs and easy to support in a user interface. Its limits should be explicit:

  • page membership can change;
  • deep offsets may become expensive in some databases;
  • totals can be stale or costly;
  • an export should not assume that page numbers create a stable snapshot.

For deep or continuous traversal, use the API’s cursor or export facility when one exists.

A practical client checklist

Before shipping a pagination loop, answer these questions:

  1. What is the documented order, including its tie-breaker?
  2. Is the cursor opaque, and are we avoiding parsing it?
  3. Which response field or link identifies the next request?
  4. Can records change position while we traverse?
  5. Does the cursor expire or depend on filters?
  6. When do we commit the checkpoint?
  7. How do we handle a repeated page?
  8. What proves that the traversal ended?
  9. Do we need a snapshot, or only a continuation point?
  10. Can we resume after a worker restart without skipping work?

Pagination code is short. Its failure model is not. The reliable implementation follows the server’s continuation signal, keeps business processing idempotent and does not treat a cursor as a promise the API never made.

Sources and version note

This guide was checked on October 26, 2026 against Stripe’s API pagination reference, GitHub’s REST API pagination guide and the GraphQL Foundation’s cursor pagination guidance. Parameter names, maximum page sizes and cursor lifetime are endpoint-specific; verify the current reference for the API you use.