GraphQL Cursor-Based Pagination
Cursor-based pagination uses a pointer — called a cursor — to mark your position in a list. Instead of asking "give me items 20–30", you ask "give me 10 items after this specific item". The result is stable even when new items are inserted or deleted, making it the correct choice for live feeds and infinite scroll.
The Cursor Is a Bookmark
Offset pagination (position-based):
─────────────────────────────────────
Items: [A B C D E F G H I J K L ...]
↑
offset=5, get next 5
Problem: If D is inserted after first page loads,
offset=5 now starts at E (skipped D)
Cursor pagination (item-based):
─────────────────────────────────
Items: [A B C D E F G H I J K L ...]
↑
cursor points to E
"Give me 5 items after E" → [F G H I J]
No matter what is inserted, cursor stays anchored to E
The Relay Connection Specification
The most widely adopted cursor pagination standard in GraphQL is the Relay Connection Spec. It uses a specific shape that many client libraries understand automatically. Learning this shape once means you can work with any API that follows it.
Connection shape:
──────────────────
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ProductEdge {
node: Product! ← The actual item
cursor: String! ← The cursor for this item
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
products(first: Int, after: String,
last: Int, before: String): ProductConnection!
}
Forward Pagination (first + after)
First page — 5 products:
──────────────────────────
{
products(first: 5) {
edges {
cursor
node { name price }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Response:
──────────
{
"products": {
"edges": [
{ "cursor": "cur_1", "node": { "name": "Laptop", "price": 899 } },
{ "cursor": "cur_2", "node": { "name": "Monitor", "price": 299 } },
{ "cursor": "cur_3", "node": { "name": "Keyboard", "price": 59 } },
{ "cursor": "cur_4", "node": { "name": "Mouse", "price": 29 } },
{ "cursor": "cur_5", "node": { "name": "Webcam", "price": 79 } }
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "cur_5"
}
}
}
Next page — use endCursor as the "after" value:
─────────────────────────────────────────────────
{
products(first: 5, after: "cur_5") {
edges { cursor node { name price } }
pageInfo { hasNextPage endCursor }
}
}
What Cursors Actually Are
A cursor is typically the base64-encoded ID or timestamp of an item. The client treats it as an opaque string — it never parses the cursor itself. Only the server knows how to decode it.
Internal cursor value: "product:101" Sent to client as: Base64 → "cHJvZHVjdDoxMDE=" Client passes it back: after: "cHJvZHVjdDoxMDE=" Server decodes it: "product:101" → WHERE id > 101
Backward Pagination (last + before)
Go backward through a list:
────────────────────────────
products(last: 5, before: "cur_10") {
edges { node { name } }
pageInfo { hasPreviousPage startCursor }
}
Key Points
- Cursor pagination anchors to an item, not a position, so it stays consistent when data changes.
- The Relay Connection Spec (edges, node, cursor, pageInfo) is the standard cursor pagination shape in GraphQL.
- Use
first+afterfor forward navigation;last+beforefor backward navigation. - Clients treat cursors as opaque strings — only the server decodes them.
- Cursor pagination is the correct choice for real-time feeds, chat history, and infinite scroll.
