You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

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.

ScenarioInvariant GuaranteeRisk Without Invariant
Paginating sorted postsOrder never changes mid-paginationItems appear twice or skip
Real-time updatesNew items appear in correct positionList becomes corrupted
Concurrent usersEach sees consistent viewRace 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 hasNextPage is true, fetching first: N, after: endCursor MUST return at least one edge
  • If hasNextPage is false, fetching more returns empty edges array
  • startCursor and endCursor match 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:

  1. Cursor Stability: User scrolls down, bookmarks position, returns later β†’ same content position
  2. Edge Ordering: Posts always appear newest-first, never shuffle randomly
  3. Page Boundary: Loading 20 more never re-shows the last post from previous batch
  4. 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.cursor might point to different message in second fetch
  • Without Page Boundary: Target message might appear in both older and newer results
  • 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

InvariantGuaranteeWhy It Matters
Cursor StabilityCursor always points to same edgeSafe bookmarking, reliable navigation
Edge OrderingConsistent sort order maintainedPredictable results, no item shuffling
Page BoundaryNo overlap or gaps between pagesComplete coverage, no duplicates
Node IdentityEach node appears once per queryClean data, accurate totals
PageInfo ConsistencyMetadata reflects actual stateCorrect UI state, proper termination
Mutation IsolationCursors survive data changesRobust 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 πŸ—οΈ

  1. Cursor Design: Use opaque, stable identifiersβ€”never expose implementation details
  2. Server Logic: Implement deterministic sorting with tie-breakers
  3. Client Code: Trust the invariantsβ€”don't work around them
  4. Testing: Verify invariants under concurrent modifications
  5. 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


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. πŸš€