Choosing Between Cursor‑Based and Offset‑Based Pagination in GraphQL APIs
A decision guide that outlines when to use Relay‑style cursor pagination versus simple offset pagination, compares their trade‑offs, and shows a minimal implementation with verification steps.
29 Apr 2026, 14:23 UTC

Decision and Constraints
When designing a GraphQL API that returns lists, you must decide which pagination strategy to support. The decision hinges on data volatility, client needs, and operational complexity.
- Adopt cursor‑based pagination (Relay Connection) for lists that can change frequently (inserts, deletions, re‑ordering) because it provides a stable view of the data across concurrent modifications.
- Use offset‑based pagination for largely static reference data where clients benefit from simple page numbers and the cost of occasional duplicates or skips is acceptable.
Constraints that apply to either approach:
- Clients must treat cursor strings as opaque tokens; they should not attempt to interpret or manipulate them.
- The backend must provide a deterministic, unique, and indexed ordering field (e.g., an auto‑increment ID or a timestamp with tie‑breaker) to generate stable cursors.
- The API versioning scheme must allow the cursor encoding to evolve (e.g., changing from base64 to a signed token) without breaking existing clients.
Comparison of Options
| Feature | Cursor‑based (Relay) | Offset‑based |
|---|---|---|
| Stability under inserts/deletes | High – no duplicates or skipped items | Low – may skip or duplicate rows when data changes |
| Implementation complexity | Moderate – requires cursor encoding/decoding logic | Low – simple LIMIT/OFFSET SQL |
| Client usability | Requires handling opaque cursors | Simple page numbers |
| Performance on large datasets | Similar with proper indexing on the ordering column | Can degrade with deep offsets because the database must skip many rows |
| Ability to jump to arbitrary page | Not direct; client must walk from the start or use a stored cursor | Direct – client can request any page number |
Trade‑offs
Cursor‑based pagination guarantees a consistent view despite concurrent modifications, but it adds server‑side work to encode and decode cursors and slightly increases payload size. Offset‑based pagination is simpler to implement and enables direct page numbers, yet it can produce inconsistent results when data changes between requests and may suffer performance penalties for deep pagination.
Implementation Example
The following example shows a minimal GraphQL schema and a resolver written in JavaScript (Node.js) that uses cursor‑based pagination. The resolver assumes a PostgreSQL‑like database with an indexed id column.
Schema (SDL)
type Connection {
edges: [Edge!]!
pageInfo: PageInfo!
}
type Edge {
cursor: String!
node: Item!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Item {
id: ID!
name: String!
# … other fields
}
type Query {
items(after: String, limit: Int = 20): Connection!
}
Resolver (pseudocode)
async function itemsResolver(parent, { after, limit = 20 }, { db }) {
// Decode cursor if present; otherwise start at offset 0
let offset = 0;
if (after) {
try {
const decoded = Buffer.from(after, 'base64').toString('utf8');
offset = parseInt(decoded, 10);
if (isNaN(offset)) throw new Error('Invalid cursor');
} catch (e) {
// Treat malformed cursor as start of list
offset = 0;
}
}
// Fetch one extra item to determine if there is a next page
const rawLimit = limit + 1;
const rows = await db.query(
`SELECT id, name FROM items ORDER BY id LIMIT $1 OFFSET $2`,
[rawLimit, offset]
);
const hasNextPage = rows.length > limit;
const data = hasNextPage ? rows.slice(0, -1) : rows;
const edges = data.map((row, idx) => ({
cursor: Buffer.from(String(offset + idx)).toString('base64'),
node: { id: row.id, name: row.name }
}));
const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
return {
edges,
pageInfo: { hasNextPage, endCursor }
};
}
Key points:
- The cursor is a base64‑encoded offset; clients must treat it as opaque.
- The resolver orders by
id, which must be unique and indexed. - Fetching
limit + 1rows lets the resolver sethasNextPageaccurately. - If the cursor cannot be decoded, the resolver safely resets to the start of the list.
Verification Steps
To confirm that the cursor‑based implementation behaves as expected, perform the following checks in a staging environment:
- Insert‑between‑pages test:
- Request the first page (e.g.,
limit=10) and record the returnedendCursor. - Insert a new item with an
idthat falls between the last returned item of page 1 and the first item that would appear on page 2. - Request the second page using the cursor from step 1.
- Verify that the inserted item does not appear in either page and that no duplicates are present.
- Request the first page (e.g.,
- Opaque cursor test:
- Take a cursor returned by the server, decode it locally (base64) and confirm it yields a numeric offset.
- Attempt to use a modified version of that string (e.g., flip a character) as the
afterargument. - Expect the resolver to either return an empty edge set or start from the beginning, never to expose internal offset values directly.
- PageInfo correctness:
- Request a page with
limitthat leaves at least one extra row in the result set. - Confirm that
hasNextPageistrueand thatendCursorencodes the offset of the last returned item. - Request the final page (where no extra row exists) and verify
hasNextPageisfalseandendCursoris either null or encodes the last item.
- Request a page with
Required permissions: the database user used by the resolver must have SELECT rights on the items table. No schema‑altering privileges are needed.
Limitations and Practical Check
Cursor‑based pagination assumes a stable ordering column. If the ordering field can change (e.g., a mutable updated_at timestamp used for sorting), cursors may become invalid, leading to missing or duplicated items. Mitigate this by using an immutable unique key (such as an auto‑increment id) or by versioning the cursor format when the ordering logic changes.
A practical way to check that the ordering column remains stable during a pagination session is to query the minimum and maximum id values returned across consecutive pages and ensure they are monotonic increasing. Any deviation indicates a problem with the ordering assumption.
Summary
Choose cursor‑based (Relay Connection) pagination for data that is subject to frequent inserts, deletions, or re‑ordering, accepting the modest increase in implementation complexity. Reserve offset‑based pagination for static lists where clients benefit from simple page numbers and occasional inconsistency is tolerable. The example resolver above demonstrates a safe, opaque‑cursor implementation, and the verification steps provide a concrete method to validate correctness in your environment.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.