Solving the 'Skipping' Problem: Implementing Cursor-Based Pagination in GraphQL
Cursor-based pagination replaces unreliable offset paging with opaque cursors, ensuring stable ordering and efficient database queries for medium-sized lists.
09 Oct 2025, 04:12 UTC

The Offset Pagination Trap
When building a feed or a list, the instinct is to use offset and limit. It feels intuitive: "Give me 20 items, starting at item 100." However, in a dynamic GraphQL API, this leads to two critical failures: unstable rankings and performance degradation.
If a new item is inserted at the top of the list while a user is scrolling, the entire dataset shifts. When the offset increases (e.g., offset: 10000), the database must still scan through those 10,000 rows before discarding them, causing latency to spike as the dataset grows.
The Cursor-Based Alternative
Cursor-based pagination replaces the numeric offset with a pointer—a cursor—to a specific record in the dataset. Instead of saying "start at row 100," the client says "give me 20 items starting after this specific ID."
In a Relay-compliant GraphQL schema, this is typically implemented using a Connection pattern. This structure provides not just the data, but metadata about the surrounding pages, such as hasNextPage and the endCursor.
The Connection Pattern Structure
- Edges: A list of objects containing the
node(the actual data) and thecursor(the pointer). - PageInfo: An object containing booleans like
hasNextPageandhasPreviousPage, and the cursors for the start and end of the current set.
Practical Implementation: The Resolver Logic
To implement this, the server must encode a unique, sequential identifier (like a timestamp or a UUID) into a base64 string. This ensures the cursor remains opaque; the client should treat it as a black box and never attempt to parse or modify it.
Example Configuration:
Assume a PostgreSQL backend with an indexed created_at column. The resolver should translate the after argument from the GraphQL query into a WHERE clause.
# Conceptual Resolver Logic (Node.js/JavaScript)
async function resolvePosts(parent, args, context) {
const { first, after } = args;
// 1. Decode the opaque cursor to get the actual ID/Timestamp
const decodedCursor = after ? Buffer.from(after, 'base64').toString() : null;
// 2. Query the DB using the cursor as a filter, not an offset
// SQL: SELECT * FROM posts WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2
const posts = await db.posts.findMany({
where: {
created_at: { lt: decodedCursor || new Date() }
},
orderBy: { created_at: 'desc' },
take: first
});
// 3. Map results to Edges and PageInfo
const edges = posts.map(post => ({
node: post,
cursor: Buffer.from(post.created_at.toISOString()).toString('base64')
}));
return {
edges,
pageInfo: {
hasNextPage: edges.length === first,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null
}
};
}
Operational Trade-offs
While cursors solve the stability and performance issues of offsets, they introduce new constraints:
- No Random Access: You cannot jump directly to "Page 5." The client must have the cursor from Page 4 to request Page 5.
- Indexing Requirements: Cursors only provide a performance boost if the column used for the cursor is indexed. A cursor on an unindexed column forces the database to perform a sequential scan, negating the benefit.
- Cursor Invalidation: If the underlying sorting logic changes (e.g., switching from
created_attopopularity_score), existing cursors stored by the client become invalid and will return incorrect results or errors.
Verification and Testing
To verify the implementation, run the following checks in your GraphQL playground or via a script:
- Sequential Traversal: Request the first page, take the
endCursor, and use it as theafterargument for the second request. Verify that the first item of page two is exactly one record after the last item of page one. - Mutation Stability: Request page one, insert a new record at the top of the dataset, and then request page two using the original
endCursor. Verify that no duplicate items appear. - Boundary Check: Request a
firstvalue larger than the total dataset size and ensurehasNextPagereturnsfalse.
Rollback and State
Since this implementation changes the API schema (moving from a simple list [Post] to a PostConnection), it is a breaking change. If you are migrating an existing API, you should deploy the cursor-based field as a new field (e.g., postsConnection) and deprecate the offset-based field before removal.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.