Pagination Invariants
Critical rules for maintaining consistent paginated data
Pagination Invariants in Relay
Understand pagination invariants with free flashcards and spaced repetition practice. This lesson covers connection stability rules, cursor semantics, edge ordering guarantees, and node consistencyβessential concepts for building robust GraphQL pagination systems with Relay.
Welcome to Pagination Invariants π
When working with Relay pagination, you're not just fetching dataβyou're maintaining a consistent view of a collection that may be changing underneath you. Pagination invariants are the unchangeable rules that Relay enforces to ensure your paginated lists behave predictably, even when the underlying data shifts. Think of them as the "laws of physics" for your connection data: they define what must always be true, regardless of mutations, additions, or deletions.
These invariants prevent common bugs like duplicate items, missing nodes, or corrupted cursor positions that plague hand-rolled pagination systems. Understanding them transforms you from someone who copies pagination code to someone who truly comprehends why Relay's design is so powerful.
Core Concepts: The Fundamental Invariants ποΈ
1. Cursor Stability Invariant π―
The most critical rule: A cursor always points to the same edge, regardless of when you use it.
query FetchUsers {
users(first: 10) {
edges {
cursor # "Y3Vyc29yOjEw" - this is STABLE
node {
id
name
}
}
}
}
If you receive cursor "Y3Vyc29yOjEw" pointing to user Alice, that cursor will always point to Alice's edge, even if:
- New users are added before Alice
- Users before Alice are deleted
- You fetch from the cursor hours or days later
π‘ Why this matters: This invariant allows you to safely bookmark positions in a list. Without it, your "page 2" might show different items each time you load it.
Implementation consequence: Cursors typically encode an opaque identifier tied to the node itself (like a timestamp, ID, or sort key), not just a numeric offset.
TIME SEQUENCE: Cursor Stability in Action
t=0: [Alice(cursor:c1), Bob(cursor:c2), Carol(cursor:c3)]
β
You save c2
t=1: New user "Zoe" inserted at start
[Zoe(cursor:c0), Alice(cursor:c1), Bob(cursor:c2), Carol(cursor:c3)]
β
c2 STILL points to Bob!
t=2: You fetch from c2
Result: [Carol, Dave, ...] β Starts AFTER Bob, as expected
2. Edge Ordering Invariant π
Within a connection, edges maintain consistent ordering based on the connection's sort criteria.
query FetchPostsByDate {
posts(first: 10, orderBy: CREATED_DESC) {
edges {
cursor
node {
id
createdAt
title
}
}
}
}
Given a specific orderBy parameter:
- Edges appear in the specified order (newest first, alphabetically, etc.)
- Subsequent pagination maintains this order
- Cursors respect this ordering for forward/backward navigation
Critical detail: If two nodes have identical sort values, the server must have a tie-breaking mechanism (usually falling back to ID) to ensure deterministic ordering.
| Scenario | Invariant Guarantee | Risk Without Invariant |
|---|---|---|
| Paginating sorted posts | Order never changes mid-pagination | Items appear twice or skip |
| Real-time updates | New items appear in correct position | List becomes corrupted |
| Concurrent users | Each sees consistent view | Race conditions |
3. Page Boundary Invariant π§
Fetching from a cursor with first: N returns exactly N edges (or fewer if end reached), starting AFTER that cursor.
Similarly, last: N returns N edges BEFORE the cursor.
## First fetch
query Initial {
items(first: 5) {
edges { cursor node { id } }
pageInfo { endCursor hasNextPage }
}
}
## Result: edges[0-4], endCursor = "cursor5"
## Second fetch MUST start AFTER cursor5
query Next {
items(first: 5, after: "cursor5") {
edges { cursor node { id } } # Returns edges[5-9]
}
}
β οΈ Common mistake: Thinking after: cursor5 includes the item at cursor5. It doesn't! The cursor is exclusive on the fetched side.
VISUAL: Pagination Boundaries
[Item1] [Item2] [Item3] β [Item4] [Item5] [Item6] β [Item7] [Item8]
cursor3 β cursor6 β
β β
first: 3, after: cursor3 β Returns [Item4, Item5, Item6]
last: 3, before: cursor6 β Returns [Item4, Item5, Item6]
β β
ββββ Same result! βββββββββ
4. Node Identity Invariant π
Each node appears at most once in a connection's result set for a given pagination query.
Relay's connection model guarantees that if you paginate through an entire connection:
- You'll see each qualifying node exactly once
- No duplicates (unless the data itself has changed)
- No gaps (missing nodes)
This is maintained through:
- Unique cursor generation per edge
- Proper offset/cursor calculation
- Transaction isolation on the backend
// Relay automatically deduplicates by node ID
const items = [];
let cursor = null;
while (hasNextPage) {
const { edges, pageInfo } = await fetchPage(cursor);
edges.forEach(edge => {
items.push(edge.node); // No duplicates due to invariant
});
cursor = pageInfo.endCursor;
hasNextPage = pageInfo.hasNextPage;
}
// items[] now contains each node exactly once
5. PageInfo Consistency Invariant βΉοΈ
The pageInfo object provides metadata that must accurately reflect the current page state:
type PageInfo {
hasNextPage: Boolean! # TRUE if more edges exist after endCursor
hasPreviousPage: Boolean! # TRUE if more edges exist before startCursor
startCursor: String # Cursor of first edge in this result
endCursor: String # Cursor of last edge in this result
}
Invariant rules:
- If
hasNextPageistrue, fetchingfirst: N, after: endCursorMUST return at least one edge - If
hasNextPageisfalse, fetching more returns empty edges array startCursorandendCursormatch the actual first/last edges returned
// This should never happen (violates invariant):
const result = await fetchConnection({ first: 10, after: cursor });
if (result.pageInfo.hasNextPage === false) {
const next = await fetchConnection({
first: 10,
after: result.pageInfo.endCursor
});
console.log(next.edges.length); // MUST be 0
}
6. Mutation Isolation Invariant π
When data changes (via mutations or external updates), active pagination cursors remain valid but:
- May point to newly inserted/deleted neighbors
- Still respect cursor stability (point to same logical position)
- Require refetch to see mutations
SCENARIO: Mid-Pagination Mutation Step 1: Fetch first page [A, B, C, D, E] β You get endCursor pointing to E Step 2: Someone deletes C (you don't know yet) Actual data: [A, B, D, E, F, G, H] Step 3: You fetch next page using endCursor from Step 1 after: cursorE β Returns [F, G, H] β Cursor still valid! Points to same position after E β οΈ You never see C's deletion unless you refetch
Design implication: Relay connections are snapshot-consistent within a query, but not real-time synchronized across queries. Use subscriptions for live updates.
Real-World Example 1: Infinite Scroll Feed π±
import { graphql, usePaginationFragment } from 'react-relay';
const FeedPagination = graphql`
fragment FeedPagination_user on User
@refetchable(queryName: "FeedPaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
after: { type: "String" }
) {
posts(first: $first, after: $after, orderBy: CREATED_DESC)
@connection(key: "FeedPagination_user_posts") {
edges {
cursor # β Cursor Stability Invariant ensures this is reliable
node {
id
content
createdAt
}
}
pageInfo {
hasNextPage # β PageInfo Consistency Invariant
endCursor
}
}
}
`;
function Feed({ user }) {
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
FeedPagination,
user
);
const handleLoadMore = () => {
if (hasNext && !isLoadingNext) {
// Edge Ordering Invariant ensures new items appear in correct order
// Page Boundary Invariant ensures no overlap or gaps
loadNext(20);
}
};
return (
<InfiniteScroll onLoadMore={handleLoadMore}>
{data.posts.edges.map(edge => (
// Node Identity Invariant ensures no duplicates
<PostCard key={edge.node.id} post={edge.node} />
))}
</InfiniteScroll>
);
}
Invariants in action:
- Cursor Stability: User scrolls down, bookmarks position, returns later β same content position
- Edge Ordering: Posts always appear newest-first, never shuffle randomly
- Page Boundary: Loading 20 more never re-shows the last post from previous batch
- Node Identity: Each post appears exactly once in the feed
Real-World Example 2: Bidirectional Pagination π
import { graphql } from 'relay-runtime';
const query = graphql`
query MessageThreadQuery($threadId: ID!, $first: Int, $after: String, $last: Int, $before: String) {
thread(id: $threadId) {
messages(first: $first, after: $after, last: $last, before: $before)
@connection(key: "MessageThread_messages") {
edges {
cursor
node {
id
content
timestamp
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
`;
// Scenario: Chat app that loads messages around a specific point
class MessageThread {
async loadAround(targetMessageId) {
// First, fetch a page that includes the target
const initial = await fetchQuery(environment, query, {
threadId: this.threadId,
first: 50 // Get 50 messages from start
});
const targetEdge = initial.thread.messages.edges.find(
e => e.node.id === targetMessageId
);
if (!targetEdge) {
// Target not in first page, need more sophisticated search
return;
}
// Now fetch BEFORE the target (older messages)
// Page Boundary Invariant: 'before' excludes targetEdge itself
const older = await fetchQuery(environment, query, {
threadId: this.threadId,
last: 25, // Get 25 messages before target
before: targetEdge.cursor
});
// And fetch AFTER the target (newer messages)
// Cursor Stability Invariant: targetEdge.cursor still valid
const newer = await fetchQuery(environment, query, {
threadId: this.threadId,
first: 25, // Get 25 messages after target
after: targetEdge.cursor
});
// Node Identity Invariant ensures no duplicates across fetches
return {
older: older.thread.messages.edges,
target: targetEdge,
newer: newer.thread.messages.edges
};
}
}
Invariants prevent bugs:
- Without Cursor Stability:
targetEdge.cursormight point to different message in second fetch - Without Page Boundary: Target message might appear in both
olderandnewerresults - Without Node Identity: Duplicate messages scattered throughout
Real-World Example 3: Handling Mutations Mid-Pagination π§
import { graphql, useMutation } from 'react-relay';
const DeletePostMutation = graphql`
mutation DeletePostMutation($postId: ID!) {
deletePost(id: $postId) {
deletedPostId
user {
posts(first: 100) @connection(key: "UserPosts_posts") {
edges {
node { id }
}
}
}
}
}
`;
function PostList() {
const [deletePost] = useMutation(DeletePostMutation);
const { data, loadNext, hasNext } = usePaginationFragment(...);
const handleDelete = (postId) => {
deletePost({
variables: { postId },
updater: (store) => {
// Mutation Isolation Invariant: Other users' cursors remain valid
// But our local connection needs updating
const deletedId = store.get(postId);
const connection = store.get('connection-key');
// Remove from local cache
const edges = connection.getLinkedRecords('edges');
const newEdges = edges.filter(
edge => edge.getLinkedRecord('node').getDataID() !== postId
);
connection.setLinkedRecords(newEdges, 'edges');
},
onCompleted: () => {
// Edge Ordering Invariant still holds
// Remaining posts maintain their order
if (hasNext) {
// Safe to continue pagination
// Page Boundary Invariant ensures no gaps
loadNext(10);
}
}
});
};
}
Key insight: The invariants allow mutations without breaking active pagination:
- Cursors of non-deleted items remain valid
- Order of remaining items is preserved
- New pagination requests work correctly
Real-World Example 4: Pagination with Filters π
query SearchProducts(
$query: String!
$minPrice: Float
$maxPrice: Float
$first: Int!
$after: String
) {
products(
search: $query
priceRange: { min: $minPrice, max: $maxPrice }
first: $first
after: $after
orderBy: RELEVANCE
) @connection(
key: "SearchProducts_products"
filters: ["search", "priceRange"] # β Important!
) {
edges {
cursor
node {
id
name
price
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Filter Invariant: Cursors are only valid within the same filter context.
// Valid: Same filters, pagination works
const page1 = await fetch({ query: "laptop", minPrice: 500, first: 10 });
const page2 = await fetch({
query: "laptop",
minPrice: 500,
first: 10,
after: page1.pageInfo.endCursor // β
Same filters
});
// Invalid: Different filters, cursor meaningless
const page1 = await fetch({ query: "laptop", minPrice: 500, first: 10 });
const page2 = await fetch({
query: "desktop", // β Different filter!
minPrice: 500,
first: 10,
after: page1.pageInfo.endCursor // β Wrong cursor context
});
// Result: Undefined behavior, likely returns wrong items or error
π‘ Best practice: Use the @connection directive's filters argument to tell Relay which arguments affect cursor validity. Relay then manages separate connections per filter combination.
Common Mistakes β οΈ
Mistake 1: Using Numeric Offsets as Cursors
// β WRONG: Breaks Cursor Stability Invariant
function generateCursor(offset) {
return btoa(`offset:${offset}`); // "offset:10"
}
// Problem:
const page1 = fetchItems({ first: 10 }); // Items 0-9
// Someone inserts 5 new items at the start
const page2 = fetchItems({
first: 10,
after: page1.endCursor // Decodes to offset:9
}); // Now returns items 9-18, including items 9 again!
β CORRECT: Use stable identifiers:
function generateCursor(item) {
// Encode something stable about the item
return btoa(JSON.stringify({
id: item.id,
timestamp: item.createdAt,
sortKey: item.relevanceScore
}));
}
// Now the cursor always points to the same item
Mistake 2: Ignoring Edge Ordering Invariant
// β WRONG: Random ordering breaks pagination
query GetPosts {
posts(first: 10, orderBy: RANDOM) { # DON'T DO THIS!
edges { node { id } }
}
}
// Every fetch returns different order β cursors meaningless
β CORRECT: Use deterministic ordering:
query GetPosts {
posts(first: 10, orderBy: {
field: CREATED_AT,
direction: DESC
}) {
edges { node { id createdAt } }
}
}
Mistake 3: Mutating Connection Without Updating Cursors
// β WRONG: Manually adding item breaks Node Identity Invariant
const updater = (store) => {
const connection = store.get('connection-id');
const edges = connection.getLinkedRecords('edges');
const newNode = store.create('new-node-id', 'Post');
// Just pushing without proper edge/cursor
edges.push(newNode); // Missing cursor, edge wrapper!
connection.setLinkedRecords(edges, 'edges');
};
β CORRECT: Use Relay's connection helpers:
import { ConnectionHandler } from 'relay-runtime';
const updater = (store) => {
const connection = ConnectionHandler.getConnection(
store.get('user-id'),
'UserPosts_posts'
);
const newEdge = ConnectionHandler.createEdge(
store,
connection,
store.get('new-post-id'),
'PostEdge' // Edge type
);
ConnectionHandler.insertEdgeBefore(connection, newEdge);
// Relay handles cursor generation and invariant maintenance
};
Mistake 4: Mixing first/after with last/before
// β WRONG: Ambiguous direction
query Confused {
items(first: 10, after: "cursorA", last: 5, before: "cursorB") {
edges { node { id } }
}
}
// Which direction are we paginating?!
β CORRECT: Use one direction per query:
// Forward pagination
query Forward {
items(first: 10, after: "cursorA") { ... }
}
// Backward pagination
query Backward {
items(last: 10, before: "cursorB") { ... }
}
Mistake 5: Assuming PageInfo Persists Across Refetches
// β WRONG: Stale pageInfo
const { pageInfo } = await fetchPage1();
// ... much later, data has changed ...
if (pageInfo.hasNextPage) { // This is STALE!
await fetchPage2(pageInfo.endCursor); // May fail or return empty
}
β CORRECT: Always use fresh pageInfo:
let currentPageInfo = null;
const page1 = await fetchPage();
currentPageInfo = page1.pageInfo; // Fresh
if (currentPageInfo.hasNextPage) {
const page2 = await fetchPage(currentPageInfo.endCursor);
currentPageInfo = page2.pageInfo; // Update to newest
}
Key Takeaways π―
π Pagination Invariants Quick Reference
| Invariant | Guarantee | Why It Matters |
|---|---|---|
| Cursor Stability | Cursor always points to same edge | Safe bookmarking, reliable navigation |
| Edge Ordering | Consistent sort order maintained | Predictable results, no item shuffling |
| Page Boundary | No overlap or gaps between pages | Complete coverage, no duplicates |
| Node Identity | Each node appears once per query | Clean data, accurate totals |
| PageInfo Consistency | Metadata reflects actual state | Correct UI state, proper termination |
| Mutation Isolation | Cursors survive data changes | Robust to concurrent modifications |
Remember: These invariants are guarantees from Relay, not suggestions. Your server implementation must honor them, and Relay's client tools assume they hold. Violating them leads to subtle, hard-to-debug issues.
Design Implications ποΈ
- Cursor Design: Use opaque, stable identifiersβnever expose implementation details
- Server Logic: Implement deterministic sorting with tie-breakers
- Client Code: Trust the invariantsβdon't work around them
- Testing: Verify invariants under concurrent modifications
- Performance: Cursors enable efficient seeking without full table scans
When Invariants Get Tricky π€
Real-time data: New items appearing mid-pagination require refetch to see, but existing cursors remain valid.
Soft deletes: Deleted items should be filtered server-side before generating edges, maintaining Node Identity Invariant.
Permission changes: If a user loses access to an item mid-pagination, that item should be excluded, but cursors to remaining items stay valid.
Time-based cursors: If ordering by timestamp, ensure ties are broken by ID to maintain Edge Ordering Invariant with microsecond-identical timestamps.
π Further Study
- Relay Cursor Connections Specification - Official spec defining all invariants
- GraphQL Cursor Pagination Best Practices - Apollo's guide to cursor semantics
- Relay Modern Pagination Container - Practical implementation patterns
Master these invariants and you'll build pagination systems that scale gracefully, handle edge cases elegantly, and provide users with consistent, reliable experiencesβeven as your data grows and changes. π