Slack's message API originally used offset/limit pagination: GET /messages?offset=1000&limit=50. It worked fine until channels had 100K+ messages. At offset=50000, the database had to scan and skip 50,000 rows before returning 50. Query time degraded from 5ms to 2 seconds. Worse, if someone posted a new message while a user was paginating, the offset shifted and they either saw duplicate messages or missed one entirely. Slack switched to cursor-based pagination, and the Relay connection spec became the standard pattern for paginated GraphQL APIs. Facebook, GitHub, Shopify, and Stripe all adopted it because it solves both performance and consistency problems.
Offset vs Cursor Pagination
OFFSET PAGINATION (fragile):
Page 1: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 0
Page 2: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 10
Page 50: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 490
^ DB scans 500 rows, discards 490!
Problem 1 - Performance:
Offset 0: 5ms (scan 10 rows)
Offset 10000: 200ms (scan 10010 rows)
Offset 100000: 2s (scan 100010 rows)
Problem 2 - Shifting window:
User loads page 1 (posts 1-10)
New post inserted! (all offsets shift +1)
User loads page 2 (offset=10) -> sees post 10 AGAIN (duplicate!)
CURSOR PAGINATION (stable):
Page 1: SELECT * FROM posts
ORDER BY id LIMIT 10
-- returns posts 1-10, cursor = post_10_id
Page 2: SELECT * FROM posts
WHERE id > 'post_10_id' -- cursor
ORDER BY id LIMIT 10
-- Uses index! O(1) seek, not O(N) scan
New post inserted? Doesn't matter.
Cursor points to a fixed row, not a position.
No duplicates, no skips.
The Relay Connection Structure
GraphQL Query Using Relay Connections:
query {
user(id: 42) {
posts(first: 10, after: "Y3Vyc29yXzEw") {
edges {
node {
id
title
createdAt
}
cursor # opaque cursor for this specific edge
}
pageInfo {
hasNextPage # are there more items after this page?
hasPreviousPage
startCursor # cursor of first item on this page
endCursor # cursor of last item (use as 'after')
}
totalCount # optional: total items (can be expensive)
}
}
}
Anatomy of the Response:
{
"posts": {
"edges": [
{
"node": { "id": "11", "title": "GraphQL Tips" },
"cursor": "Y3Vyc29yXzEx" # base64(cursor_11)
},
{
"node": { "id": "12", "title": "Scaling Redis" },
"cursor": "Y3Vyc29yXzEy" # base64(cursor_12)
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yXzIw" # pass this as 'after'
},
"totalCount": 156
}
}
Why This Structure Matters
Why Edges (not just nodes)?
Edges can carry relationship metadata:
friendships(first: 10) {
edges {
node { id, name, avatar } # the friend
cursor
friendsSince # metadata about the RELATIONSHIP
mutualFriends # not on User, on the edge
}
}
Without edges, you'd have to put 'friendsSince' on the User
type, which makes no sense (it's per-relationship, not per-user).
Why Opaque Cursors?
Cursor = base64("created_at:2024-01-15T10:00:00Z,id:42")
Client sees: "Y3JlYXRlZF9hdDoyMDI0LTAxL..."
Why opaque?
- Backend can change cursor format without breaking clients
- Prevents clients from constructing/modifying cursors
- Can encode composite keys (timestamp + id for stable ordering)
Navigation Directions:
Forward: first: 10, after: "cursorX" (next 10 after X)
Backward: last: 10, before: "cursorY" (prev 10 before Y)
First page: first: 10 (no cursor needed)
Interview Tip
When discussing pagination in any API design, explain why cursor-based beats offset: 'Offset pagination degrades linearly - the database must scan and skip N rows for offset N, making page 1000 much slower than page 1. With cursor-based pagination, the database seeks directly to the cursor position using an index, giving O(1) performance at any depth. Cursors are also stable - new inserts do not cause duplicate or missing items across pages. The Relay connection spec standardizes this with edges (for relationship metadata), nodes (the data), and pageInfo (for navigation). I make cursors opaque (base64-encoded) so the backend can change the cursor format without breaking clients.'
<Architecture overview
/blockquote>
Key Takeaway
Relay-style connections standardize cursor-based pagination in GraphQL with edges (relationship + cursor), nodes (the data), and pageInfo (hasNextPage, endCursor). Cursor-based pagination gives O(1) performance at any depth and stable results even during concurrent writes. Use opaque cursors, support bidirectional navigation, and make totalCount optional since it can be expensive on large datasets.
No comments yet. Be the first to start the thread!
Related Articles
Enjoyed this content?
Your support keeps us creating free resources
We put a lot of hours into researching and writing these guides. If it helped you, consider buying us a coffee. Every bit goes toward keeping ScaleDojo's content free and growing.
$
One-time payment via Stripe. ScaleDojo account required.