Cursor-Based Pagination for GraphQL Feeds
Offset pagination breaks on active feeds. Learn when Relay-style cursor connections fit your GraphQL list endpoint and how to verify them.
15 Dec 2025, 21:53 UTC

The problem: offset pagination assumes a static list
Many GraphQL APIs start with offset-style arguments:
posts(offset: Int, limit: Int): [Post]
This works until items are inserted or deleted between requests. If a new post is added after page one, offset: 10 on the next request can return an item the user already saw, or skip one entirely. For activity streams and infinite-scroll feeds, that is a visible bug.
What a cursor connection looks like
Relay-style connections replace plain lists with a wrapper type. A connection holds pagination metadata and a list of edges. Each edge pairs an opaque cursor with the actual item, called the node. Opaque means the client must treat the cursor as a token and never parse or guess its format.
A common schema shape is:
type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
posts(first: Int, after: String): PostConnection!
}
The client requests a page with first and after. The server returns pageInfo.endCursor, which the client sends back as after on the next request. Because the cursor is tied to a stable sort position rather than a row number, inserts and deletions between pages are less likely to cause duplicates or gaps.
Worked example: fetch the first page, then the next
Run this in an authenticated GraphQL client or playground. You typically need a bearer token with read access to the list resource.
query FeedPage($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
cursor
node {
id
title
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
First request variables:
{ "first": 10, "after": null }
Expected check: the response contains up to 10 edges, and pageInfo.hasNextPage is true if more items exist. Capture pageInfo.endCursor.
Next request variables, replacing <END_CURSOR> with the captured value:
{ "first": 10, "after": "" }
Expected check: the first node.id in the second response differs from the last node.id in the first response, and results continue from that point.
Risk: if the cursor is invalid or expired, some servers return an empty page while others return an error. Neither should produce a 500; if yours does, treat it as a server bug.
Trade-offs and operational limits
- Total counts are expensive.
totalCountoften requires a separate full scan. Avoid using it for pagination UI; rely onhasNextPageinstead. - Sort stability matters. Use a stable, immutable sort key such as
createdAtcombined with a unique ID. Without it, inserts between pages can still duplicate or skip items. - Authorization is per edge. Connection metadata must not leak the existence of items a user cannot read; filter or authorize each node individually.
- Page size should be capped. Allowing
first: 10000can degrade database performance. Enforce a maximum and return a validation or business-logic error above it.
Practical verification
- Inspect the schema for connection types containing
pageInfo { endCursor hasNextPage }and list fields acceptingfirst/after. - Run a query with
first: 5, captureendCursor, then issue the next query withafterset to that cursor and confirm results continue after the previous last item. - Test an invalid cursor to confirm the server returns a clear error or an empty page instead of a server error.
- Request
first: 10000and confirm the server rejects it rather than attempting the query.
Closing
Cursor connections add schema complexity and remove simple page numbers, but they are the safer default for GraphQL feeds and activity streams. Start with first/after and pageInfo, enforce a maximum page size, and never let the client interpret the cursor. If your list is mostly static and users jump to arbitrary pages, offset pagination may still be simpler; otherwise, prefer the cursor.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.