How Cursor‑Based Pagination Solves N+1 in GraphQL
Facing N+1 query storms in GraphQL? Cursor‑based pagination is the practical fix—this guide shows how to define Connection types, write resolvers, and avoid pitfalls.
25 Feb 2026, 00:41 UTC

Problem: The N+1 Query Storm
When a client requests a list of items, a naive GraphQL resolver might fetch the list first and then resolve each node individually. If the list contains 50 products, the server ends up running 51 database queries: one for the list and one for each product. This N+1 pattern blows up latency and load, especially for large lists.
Cursor Pagination Basics
Cursor‑based pagination lets a single query return a slice of results using opaque cursor tokens. The schema defines a Connection type with edges and pageInfo, and the resolver returns a slice and a cursor that points to the next item. The client can then fetch subsequent pages by passing the returned cursor.
Connection Type Pattern
type ProductEdge {
node: Product!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
products(first: Int!, after: String): ProductConnection!
}
Here first limits the number of items and after is the opaque cursor returned from the previous page.
Implementing the Resolver
Below is a typical resolver in Node.js using a SQL database. The key is to build a single query that fetches first + 1 rows; the extra row tells us whether there is a next page.
async function productsResolver(parent, args, context) {
const limit = args.first + 1
const cursor = args.after
const offsetQuery = cursor
? `WHERE id > ${cursor}`
: ''
const rows = await context.db.query(
`SELECT id, name, price FROM products ${offsetQuery} ORDER BY id ASC LIMIT ${limit}`
)
const hasNextPage = rows.length === limit
const edges = rows
.slice(0, hasNextPage ? -1 : rows.length)
.map(row => ({
node: { id: row.id, name: row.name, price: row.price },
cursor: row.id.toString(), // simple example; consider base64
}))
return {
edges,
pageInfo: {
hasNextPage,
endCursor: hasNextPage ? rows[rows.length - 1].id.toString() : null,
},
}
}
Key points:
- Only one database round‑trip per page.
- The cursor is opaque to the client; you can wrap it in base64 or encrypt it.
- Index on
id(or the ordering column) is essential for performance.
Concrete Example Flow
- The client sends
first: 10. - The server runs a single query for 11 rows, returns 10 edges and a cursor pointing to the 11th row.
- The client receives
pageInfo.hasNextPage: trueandendCursor. - To fetch the next page, the client repeats the query with
after: "cursor". - The cycle continues until
hasNextPageis false.
During this process, the database sees only one query per page, regardless of list size.
Trade‑offs & Limitations
- State Management: The client must keep the cursor token and handle page navigation. This adds complexity compared to simple offset pagination.
- Random Access: Jumping to an arbitrary page (e.g., page 5) is not straightforward. You need additional logic or a separate API.
- Enumeration Risk: If the cursor is just a predictable ID, an attacker could enumerate items. Encode or sign cursors to mitigate.
- Large Sets & Depth Limits: Very deep pagination can hit query depth limits. Consider limiting page size or using a cursor that includes a hash of the last seen value.
Actionable Checklist for Production
- Define Connection types for all list fields that can be paginated.
- Implement resolvers that fetch
first + 1rows and computehasNextPage. - Index the ordering column(s) used in the query.
- Encode cursors (e.g., base64) and optionally sign them to prevent enumeration.
- Test the query with a large dataset: verify only one DB query per page and measure latency.
- Document pagination limits (max
firstvalue) to protect against abuse. - Provide client helpers to automatically handle cursors and page navigation.
By following this pattern, you can eliminate the N+1 problem, reduce database load, and provide a smooth user experience for list views.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.