Using Cursor-Based Pagination in GraphQL to Avoid Offset Pitfalls
Learn how cursor-based pagination works in GraphQL, why it outperforms offset‑limit queries, and see a concrete Apollo Client example you can adapt.
20 Jan 2026, 18:50 UTC

The problem with offset pagination
When a GraphQL API returns a list with limit and offset arguments, the database must skip over all preceding rows for each request. As the offset grows, the query scans more and more data, turning what should be a constant‑time lookup into an O(N) operation. In addition, if items are inserted or deleted between pages, the same record can appear twice or be skipped entirely.
Cursor pagination basics
Cursor‑based pagination replaces the numeric offset with an opaque string that identifies a specific position in the ordered set. The server returns this string in the pageInfo field of a Relay‑style connection:
query Users($first: Int, $after: String) {
users(first: $first, after: $after) {
edges {
node { id name }
}
pageInfo {
endCursor
hasNextPage
}
}
}
The client sends the after variable with the cursor received from the previous response. Because the cursor encodes the sort key (commonly the primary key id or a composite key), the database can locate the starting point with an indexed lookup, yielding O(log N) retrieval regardless of page depth.
Apollo Client worked example
Assume a simple user list where the server follows the Relay Connection spec. The initial request asks for the first three users:
// src/graphql/users.query.gql
query UsersList($first: Int!, $after: String) {
users(first: $first, after: $after) {
edges {
node { id name }
}
pageInfo {
endCursor
hasNextPage
}
}
}
Execute it with Apollo Client’s useQuery hook:
import { useQuery, gql } from '@apollo/client';
import UsersList from './graphql/users.query.gql';
function Users() {
const { data, loading, error, fetchMore } = useQuery(UsersList, {
variables: { first: 3, after: null },
notifyOnNetworkStatusChange: true,
});
const loadMore = () => {
if (!data?.users.pageInfo.hasNextPage) return;
fetchMore({
variables: { after: data.users.pageInfo.endCursor },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
users: {
__typename: 'UsersConnection',
edges: [...prev.users.edges, ...fetchMoreResult.users.edges],
pageInfo: fetchMoreResult.users.pageInfo,
},
};
},
});
};
if (loading) return Loading…
;
if (error) return Error: {error.message}
;
return (
{data.users.edges.map(edge => (
{edge.node.name}
))}
Load more
);
}
The fetchMore call uses the endCursor from the previous result as the after variable. Apollo’s update function concatenates the new edges with the existing list, giving a seamless infinite‑scroll experience.
Limitations and how to verify
Cursor pagination relies on a stable ordering. If rows are inserted or deleted between requests, the client may see duplicates or gaps unless the server ties the cursor to an immutable key (e.g., a UUID combined with a timestamp) or uses a transactional snapshot. To check that your implementation works correctly:
- Run the initial query with
first: 3and noafter. Record the returnedendCursor(call itC1) and the IDs of the three nodes. - Execute the same query with
after: C1. Verify that the first node’s ID is greater than the last ID from step 1 and that none of the IDs from step 1 appear again. - Repeat the process a few times, ensuring
hasNextPagebecomes false only after the final page. - For performance confirmation, compare response times of cursor‑based queries against offset‑based queries on a dataset of at least 10 k rows; cursor times should remain roughly flat while offset times grow.
These steps require only read access to the underlying data store (e.g., a PostgreSQL users table) and can be performed in GraphQL Playground, Apollo Studio, or any HTTP client.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.