Lists, Scale, and Reality
Handle large lists properly using Relay's connection specification and pagination patterns
Lists, Scale, and Reality
Master Relay pagination with free flashcards and proven techniques for handling real-world data challenges. This lesson covers cursor-based pagination, connection patterns, and performance optimizationβessential concepts for building scalable GraphQL applications with Relay.
Welcome π
When you're building modern web applications, displaying data isn't just about showing a few items on screen. What happens when you need to show 10,000 products? A million social media posts? Real-world applications deal with massive datasets that can't simply be loaded all at once. This is where Relay's approach to lists becomes absolutely critical.
In this lesson, we'll explore how Relay solves one of the most common challenges in web development: efficiently displaying large collections of data. You'll learn why Facebook invented the Connection pattern, how cursor-based pagination works under the hood, and practical strategies for keeping your applications fast even as your data grows.
Core Concepts π
The Problem with Naive List Rendering
Imagine you're building an e-commerce site with 50,000 products. If you tried to fetch and render all products at once, you'd face several catastrophic problems:
β NAIVE APPROACH: Fetch Everything βββββββββββββββββββββββββββββββββββββββ β Request: getAllProducts() β β Response: [50,000 products...] β β Size: ~50 MB of JSON β β Time: 30+ seconds β β Browser: π₯ CRASH β βββββββββββββββββββββββββββββββββββββββ π User Experience: π Long wait β π΄ User leaves πΎ Memory spike β π₯ Browser freezes π± Mobile device β β οΈ App killed
The fundamental issue: Networks have limited bandwidth, browsers have limited memory, and users have limited patience.
The Solution: Pagination
Pagination is the practice of breaking large datasets into smaller, manageable chunks. Instead of loading 50,000 products, you load 20 at a time:
PAGINATION APPROACHES
ββββββββββββββββββββββββββββββββββββββββββββ
β OFFSET-BASED (Traditional) β
β ββββββββ¬βββββββ¬βββββββ¬βββββββ β
β βPage 1βPage 2βPage 3βPage 4β β
β β 1-20 β21-40 β41-60 β61-80 β β
β ββββββββ΄βββββββ΄βββββββ΄βββββββ β
β API: ?page=2&limit=20 β
β SQL: LIMIT 20 OFFSET 20 β
ββββββββββββββββββββββββββββββββββββββββββββ
β οΈ
ββββββββββββββββββββββββββββββββββββββββββββ
β CURSOR-BASED (Relay's Approach) β
β ββββββββ ββββββββ ββββββββ ββββββββ β
β βItem AβββItem BβββItem CβββItem Dβ β
β ββββββββ ββββββββ ββββββββ ββββββββ β
β Cursors: ["XyZ1","AbC2","DeF3"...] β
β API: after="AbC2", first=20 β
ββββββββββββββββββββββββββββββββββββββββββββ
Why Cursor-Based Pagination Wins
Offset-based pagination seems simple, but it has critical flaws:
| Problem | Scenario | Result |
|---|---|---|
| π Data Changes | User on page 2, new item added to page 1 | Item from page 2 appears twice on page 3 |
| ποΈ Deletions | Item deleted from page 1 while viewing page 2 | Skip an item entirely |
| β‘ Performance | Request page 1000 with OFFSET 20000 | Database must scan 20,000 rows to skip them |
| π Real-time Sync | Multiple users adding/removing items | Inconsistent pagination state |
Cursor-based pagination solves these problems by using opaque tokens (cursors) that point to specific positions in the dataset:
π‘ Key Insight: A cursor is like a bookmark in a book. No matter how many pages are added or removed before your bookmark, it still points to the same content.
The Connection Pattern π
Relay standardizes list data using the Connection pattern, a GraphQL convention that provides rich pagination metadata:
RELAY CONNECTION STRUCTURE
βββββββββββββββββββββββββββββββββββββββββββββββ
β CONNECTION β
β βββββββββββββββββββββββββββββββββββββββββ β
β β edges: [ β β
β β { β β
β β node: { id, name, ... } ββ Actual data
β β cursor: "Y3Vyc29yOjE=" ββ Position marker
β β }, β β
β β { node: {...}, cursor: "..." } β β
β β ] β β
β βββββββββββββββββββββββββββββββββββββββββ β
β βββββββββββββββββββββββββββββββββββββββββ β
β β pageInfo: { β β
β β hasNextPage: true ββ More items?
β β hasPreviousPage: false β β
β β startCursor: "Y3Vyc29yOjE=" β β
β β endCursor: "Y3Vyc29yOjIw" β β
β β } β β
β βββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Breaking it down:
- edges: Array of items, each containing:
- node: The actual data object (product, user, post, etc.)
- cursor: Opaque string identifying this item's position
- pageInfo: Metadata for pagination UI:
- hasNextPage: Boolean indicating if more items exist after this page
- hasPreviousPage: Boolean indicating if items exist before this page
- startCursor: Cursor of the first item in this page
- endCursor: Cursor of the last item in this page
Cursors: The Magic Tokens π«
What exactly is a cursor? It's an opaque string that encodes positional information:
## Common cursor encoding (base64)
"Y3Vyc29yOjE=" // Decodes to: "cursor:1"
"Y3Vyc29yOjIw" // Decodes to: "cursor:20"
π‘ Important: Cursors are opaque to clientsβyou should never parse or construct them manually. The server generates them, and clients use them as-is.
Cursors can encode various strategies:
- ID-based:
cursor:product-123(works with any unique identifier) - Timestamp-based:
cursor:2024-01-15T10:30:00Z(for chronological data) - Composite:
cursor:2024-01-15:product-123(combines multiple factors) - Encrypted: Encrypted positional data for security
Pagination Arguments π
Relay uses four standard arguments for pagination:
| Argument | Type | Purpose | Example |
|---|---|---|---|
| first | Int | Take N items forward | first: 10 |
| after | String | Start after this cursor | after: "Y3Vyc29yOjE=" |
| last | Int | Take N items backward | last: 10 |
| before | String | Start before this cursor | before: "Y3Vyc29yOjIw" |
Forward pagination (most common):
query {
products(first: 20, after: "cursor123") {
edges {
node { name }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Backward pagination (for "previous page" buttons):
query {
products(last: 20, before: "cursor123") {
edges {
node { name }
cursor
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
Scale Considerations: Performance at Reality ποΈ
When your application grows from hundreds to millions of records, pagination strategy becomes critical:
Database Query Performance
QUERY PERFORMANCE COMPARISON ββββββββββββββββββββββββββββββββββββββββββ β OFFSET-BASED (Gets worse with scale) β β β β SELECT * FROM products β β LIMIT 20 OFFSET 1000000; β β β β Database must: β β 1. Scan 1,000,000 rows β±οΈ 8.2s β β 2. Skip all of them β±οΈ β β 3. Return 20 rows β±οΈ β β β β Total: ~8-10 seconds π β ββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ β CURSOR-BASED (Consistent performance) β β β β SELECT * FROM products β β WHERE id > 'cursor-value' β β ORDER BY id β β LIMIT 20; β β β β Database uses: β β 1. Index seek directly β±οΈ 0.02s β β 2. Return 20 rows β±οΈ β β β β Total: ~20-50ms β‘ β ββββββββββββββββββββββββββββββββββββββββββ
Why cursor-based is faster: It uses indexed seeks instead of table scans. The database jumps directly to the cursor position rather than counting from the beginning.
π‘ Pro Tip: For cursor-based pagination to be fast, ensure your cursor field is indexed!
-- Make sure your cursor column has an index
CREATE INDEX idx_products_id ON products(id);
CREATE INDEX idx_posts_created_at ON posts(created_at);
Memory Management
Relay's approach also helps with client-side memory:
MEMORY USAGE: Infinite Scroll Example ββββββββββββββββββββββββββββββββββββββββββ β WITHOUT RELAY β β β β User scrolls β Append to array β β [item1, item2, ... item10000] πΎ 500MB β β β β Problem: Array grows forever β β Result: Browser crashes π₯ β ββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ β WITH RELAY STORE β β β β User scrolls β Store manages cache β β Cache: 200 items in memory πΎ 10MB β β β β Strategy: Keep visible + nearby items β β Result: Smooth scrolling β β ββββββββββββββββββββββββββββββββββββββββββ
Relay's store can:
- Cache intelligently: Keep frequently accessed items
- Evict strategically: Remove items far from viewport
- Deduplicate: Same item in multiple queries stored once
Implementing Load More with Relay π
Here's how you implement "Load More" functionality with Relay's hooks:
import { graphql, usePaginationFragment } from 'react-relay';
function ProductList({ queryRef }) {
const {
data,
loadNext,
hasNext,
isLoadingNext,
} = usePaginationFragment(
graphql`
fragment ProductList_query on Query
@refetchable(queryName: "ProductListPaginationQuery") {
products(first: $count, after: $cursor)
@connection(key: "ProductList_products") {
edges {
node {
id
name
price
}
}
}
}
`,
queryRef
);
return (
<div>
{data.products.edges.map(({ node }) => (
<ProductCard key={node.id} product={node} />
))}
{hasNext && (
<button
onClick={() => loadNext(20)}
disabled={isLoadingNext}
>
{isLoadingNext ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
What's happening here:
usePaginationFragment: Relay hook that provides pagination capabilities@refetchable: Directive telling Relay this fragment can be refetched with new arguments@connection: Directive that tells Relay to manage this as a connection (append new items to existing list)loadNext(20): Function to fetch the next 20 itemshasNext: Boolean frompageInfo.hasNextPageisLoadingNext: Loading state for the pagination request
The @connection Directive π
The @connection directive is Relay's secret weapon for managing paginated lists:
fragment Feed_query on Query {
posts(first: $count, after: $cursor)
@connection(key: "Feed_posts") {
edges {
node {
id
content
}
}
}
}
What @connection does:
- Assigns a stable key:
"Feed_posts"uniquely identifies this connection in Relay's store - Merges paginated results: When you load more, new items are appended to existing ones
- Handles cache updates: Insertions, deletions, and updates work correctly
- Enables optimistic updates: You can add items before server confirmation
π‘ Connection keys must be unique per component. If two components use the same connection key, they'll share the same cached data (which might be what you want, or might cause bugs!).
Examples with Explanations π―
Example 1: Building an Infinite Scroll Feed
Scenario: You're building a social media feed that loads more posts as the user scrolls.
import { graphql, usePaginationFragment } from 'react-relay';
import { useEffect, useRef } from 'react';
function InfiniteFeed({ queryRef }) {
const {
data,
loadNext,
hasNext,
isLoadingNext,
} = usePaginationFragment(
graphql`
fragment InfiniteFeed_query on Query
@refetchable(queryName: "InfiniteFeedPaginationQuery") {
posts(first: $count, after: $cursor)
@connection(key: "InfiniteFeed_posts") {
edges {
node {
id
content
author {
name
avatar
}
createdAt
}
}
}
}
`,
queryRef
);
// Ref to the loading sentinel element
const sentinelRef = useRef(null);
useEffect(() => {
// Set up Intersection Observer for automatic loading
const observer = new IntersectionObserver(
(entries) => {
// When sentinel becomes visible and we have more items
if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
loadNext(10); // Load 10 more posts
}
},
{ threshold: 0.5 } // Trigger when 50% visible
);
if (sentinelRef.current) {
observer.observe(sentinelRef.current);
}
return () => observer.disconnect();
}, [hasNext, isLoadingNext, loadNext]);
return (
<div className="feed">
{data.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{/* Invisible sentinel element */}
{hasNext && (
<div ref={sentinelRef} style={{ height: '20px' }}>
{isLoadingNext && <Spinner />}
</div>
)}
{!hasNext && <p>You've reached the end! π</p>}
</div>
);
}
Key techniques:
- Intersection Observer API: Detects when the sentinel element enters the viewport
- Automatic loading: Fetches more items without requiring a button click
- Loading states: Shows spinner while fetching
- End detection: Displays message when no more items exist
π§ Memory tip: Think of the sentinel as a tripwireβwhen the user scrolls far enough to "trip" it, you load more content.
Example 2: Bidirectional Pagination with Page Numbers
Scenario: Building a traditional paginated table with "Previous" and "Next" buttons.
import { graphql, usePaginationFragment } from 'react-relay';
import { useState } from 'react';
function PaginatedTable({ queryRef }) {
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 25;
const {
data,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment(
graphql`
fragment PaginatedTable_query on Query
@refetchable(queryName: "PaginatedTableQuery") {
users(first: $count, after: $cursor)
@connection(key: "PaginatedTable_users") {
edges {
node {
id
name
email
role
}
}
pageInfo {
startCursor
endCursor
}
}
}
`,
queryRef
);
const handleNext = () => {
if (hasNext) {
loadNext(PAGE_SIZE);
setCurrentPage(prev => prev + 1);
}
};
const handlePrevious = () => {
if (hasPrevious) {
loadPrevious(PAGE_SIZE);
setCurrentPage(prev => prev - 1);
}
};
return (
<div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{data.users.edges.map(({ node }) => (
<tr key={node.id}>
<td>{node.name}</td>
<td>{node.email}</td>
<td>{node.role}</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<button
onClick={handlePrevious}
disabled={!hasPrevious || isLoadingPrevious}
>
β Previous
</button>
<span>Page {currentPage}</span>
<button
onClick={handleNext}
disabled={!hasNext || isLoadingNext}
>
Next β
</button>
</div>
</div>
);
}
Important considerations:
loadPrevious: Works withlastandbeforearguments under the hood- Button states: Disabled when loading or no more pages
- Page tracking: Local state tracks current page number for UI
β οΈ Common mistake: Don't try to calculate total pages with cursor paginationβyou can't know the total without counting all items (which defeats the purpose of pagination).
Example 3: Search with Pagination
Scenario: Search results that update as the user types, with pagination for large result sets.
import { graphql, usePaginationFragment } from 'react-relay';
import { useState, useTransition } from 'react';
import { useQueryLoader } from 'react-relay';
function SearchResults({ queryRef }) {
const [searchTerm, setSearchTerm] = useState('');
const [isPending, startTransition] = useTransition();
const {
data,
loadNext,
hasNext,
refetch,
} = usePaginationFragment(
graphql`
fragment SearchResults_query on Query
@refetchable(queryName: "SearchResultsPaginationQuery")
@argumentDefinitions(
searchTerm: { type: "String", defaultValue: "" }
) {
searchProducts(query: $searchTerm, first: $count, after: $cursor)
@connection(key: "SearchResults_searchProducts") {
edges {
node {
id
name
description
price
}
}
}
}
`,
queryRef
);
const handleSearch = (newTerm) => {
setSearchTerm(newTerm);
// Use transition to keep UI responsive during refetch
startTransition(() => {
refetch(
{ searchTerm: newTerm, count: 20 },
{ fetchPolicy: 'store-and-network' }
);
});
};
return (
<div>
<input
type="search"
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search products..."
/>
{isPending && <div>Searching...</div>}
<div className="results">
{data.searchProducts.edges.length === 0 ? (
<p>No results found for "{searchTerm}"</p>
) : (
data.searchProducts.edges.map(({ node }) => (
<ProductCard key={node.id} product={node} />
))
)}
</div>
{hasNext && (
<button onClick={() => loadNext(20)}>
Load More Results
</button>
)}
</div>
);
}
Advanced patterns here:
refetch: Starts a new query with different variables (new search term)useTransition: React 18 hook that keeps UI responsive during updates@argumentDefinitions: Declares variables the fragment depends onfetchPolicy: 'store-and-network': Shows cached results immediately, then updates with fresh data
π‘ Performance tip: Add debouncing to avoid refetching on every keystroke:
import { useDebouncedCallback } from 'use-debounce';
const debouncedSearch = useDebouncedCallback(
(term) => {
startTransition(() => {
refetch({ searchTerm: term, count: 20 });
});
},
300 // Wait 300ms after typing stops
);
Example 4: Handling Real-Time Updates in Paginated Lists
Scenario: A chat application where new messages arrive while viewing paginated history.
import { graphql, usePaginationFragment, useSubscription } from 'react-relay';
import { useEffect } from 'react';
function ChatHistory({ conversationId, queryRef }) {
const {
data,
loadPrevious, // Load older messages
hasPrevious,
} = usePaginationFragment(
graphql`
fragment ChatHistory_conversation on Conversation
@refetchable(queryName: "ChatHistoryPaginationQuery") {
messages(last: $count, before: $cursor)
@connection(key: "ChatHistory_messages") {
edges {
node {
id
content
sender {
name
}
timestamp
}
}
}
}
`,
queryRef
);
// Subscribe to new messages
useSubscription(
React.useMemo(
() => ({
subscription: graphql`
subscription ChatHistoryNewMessageSubscription($conversationId: ID!) {
newMessage(conversationId: $conversationId) {
message {
id
content
sender {
name
}
timestamp
}
}
}
`,
variables: { conversationId },
onNext: (response) => {
// New message automatically added to connection by Relay
console.log('New message received:', response.newMessage);
},
}),
[conversationId]
)
);
return (
<div className="chat-container">
{hasPrevious && (
<button onClick={() => loadPrevious(20)}>
Load Older Messages
</button>
)}
<div className="messages">
{data.messages.edges.map(({ node }) => (
<div key={node.id} className="message">
<strong>{node.sender.name}:</strong> {node.content}
<span className="timestamp">{node.timestamp}</span>
</div>
))}
</div>
</div>
);
}
Real-time integration:
- Subscriptions: WebSocket-based updates for new messages
- Automatic merging: Relay adds new items to the connection automatically
- Backward pagination: Uses
lastandbeforefor chat history (newest first) - Connection stability: Cursors remain valid even as new messages arrive
π Real-world insight: Facebook Messenger uses this exact patternβyou can scroll up to load history while new messages appear at the bottom.
Common Mistakes β οΈ
Mistake 1: Not Using Connection Keys Properly
β Wrong:
fragment UserList_query on Query {
users(first: $count, after: $cursor)
@connection(key: "users") { # Too generic!
edges { node { id name } }
}
}
fragment AdminList_query on Query {
users(first: $count, after: $cursor)
@connection(key: "users") { # Same key!
edges { node { id name } }
}
}
Problem: Both components share the same connection cache, causing bugs when they should show different data.
β Correct:
fragment UserList_query on Query {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") { # Component-specific
edges { node { id name } }
}
}
fragment AdminList_query on Query {
users(first: $count, after: $cursor, role: ADMIN)
@connection(key: "AdminList_users") { # Different key
edges { node { id name } }
}
}
Mistake 2: Forgetting to Handle Empty States
β Wrong:
return (
<div>
{data.products.edges.map(({ node }) => (
<ProductCard key={node.id} product={node} />
))}
{/* What if edges is empty? */}
</div>
);
Problem: Users see a blank screen with no explanation.
β Correct:
return (
<div>
{data.products.edges.length === 0 ? (
<div className="empty-state">
<p>No products found</p>
<button onClick={onCreateProduct}>Add Your First Product</button>
</div>
) : (
data.products.edges.map(({ node }) => (
<ProductCard key={node.id} product={node} />
))
)}
</div>
);
Mistake 3: Loading Too Many Items at Once
β Wrong:
// Load 1000 items at once
loadNext(1000);
Problem:
- Huge network payload
- Long wait time
- Memory spike
- Poor user experience
β Correct:
// Load reasonable chunks
const PAGE_SIZE = 20; // Or 50 for large screens
loadNext(PAGE_SIZE);
π‘ Guidelines for page size:
- Mobile: 10-20 items
- Desktop: 20-50 items
- Large screens: 50-100 items
- Never exceed: 100 items per page
Mistake 4: Not Checking Loading States
β Wrong:
<button onClick={() => loadNext(20)}>
Load More
</button>
Problem: Users can spam-click, causing multiple simultaneous requests.
β Correct:
<button
onClick={() => loadNext(20)}
disabled={isLoadingNext || !hasNext}
>
{isLoadingNext ? 'Loading...' : 'Load More'}
</button>
Mistake 5: Ignoring Cursor Expiration
β Wrong:
// Store cursor in localStorage for days
localStorage.setItem('lastCursor', endCursor);
// Later, use stale cursor
const cursor = localStorage.getItem('lastCursor');
refetch({ after: cursor }); // Might be invalid!
Problem: Cursors can expire or become invalid if the underlying data changes significantly.
β Correct:
// Handle cursor errors gracefully
const [error, setError] = useState(null);
try {
loadNext(20);
} catch (err) {
if (err.message.includes('Invalid cursor')) {
// Restart from beginning
refetch({ after: null, count: 20 });
}
}
Mistake 6: Not Optimizing Database Queries
β Wrong server implementation:
// Resolver without proper indexing
Query: {
products: async (_, { first, after }) => {
// Inefficient: Full table scan then filter
const allProducts = await db.products.findAll();
const startIndex = after ? findIndex(after) : 0;
return allProducts.slice(startIndex, startIndex + first);
}
}
β Correct server implementation:
Query: {
products: async (_, { first, after }) => {
// Efficient: Use indexed WHERE clause
const query = db.products
.where('id', '>', decodeCursor(after))
.orderBy('id', 'asc')
.limit(first);
return query.execute();
}
}
Server-side best practices:
- β Index cursor fields (id, created_at, etc.)
- β Use compound indexes for complex sorts
- β
Set reasonable max page size (prevent
first: 1000000) - β Add query timeouts
- β Monitor slow queries
Key Takeaways π―
π Quick Reference: Relay Pagination Essentials
| Concept | Key Points |
|---|---|
| Why Cursor Pagination? |
β’ Handles data changes gracefully β’ Consistent performance at scale β’ No duplicate or skipped items β’ Works with real-time updates |
| Connection Structure |
β’ edges: Array of {node, cursor}β’ pageInfo: {hasNextPage, hasPreviousPage, startCursor, endCursor}β’ node: Your actual data
|
| Pagination Arguments |
β’ first + after: Forward paginationβ’ last + before: Backward paginationβ’ Never expose cursor internals to clients |
| Essential Directives |
β’ @connection(key: "unique"): Manages list stateβ’ @refetchable: Enables refetching with new variablesβ’ @argumentDefinitions: Declares fragment variables
|
| Key Hook Functions |
β’ loadNext(n): Fetch next n itemsβ’ loadPrevious(n): Fetch previous n itemsβ’ refetch(vars): Start fresh queryβ’ hasNext / hasPrevious: Check availability
|
| Performance Tips |
β’ Keep page sizes reasonable (20-50 items) β’ Index cursor fields in database β’ Use Intersection Observer for infinite scroll β’ Debounce search queries β’ Handle loading and empty states |
Remember These Golden Rules π
- Cursors are opaque: Never parse or construct them manually
- Connection keys must be unique: One key per component/use case
- Always check hasNext/hasPrevious: Don't try to load beyond boundaries
- Handle all states: Loading, empty, error, and success
- Optimize from the start: Index database fields, reasonable page sizes
- Think about scale: What works for 100 items should work for 1,000,000
- Test edge cases: Empty lists, single item, network failures
Further Study π
Deepen your understanding with these resources:
Relay Pagination Documentation: https://relay.dev/docs/guided-tour/list-data/pagination/ - Official guide with detailed API reference
GraphQL Cursor Connections Specification: https://relay.dev/graphql/connections.htm - The standard specification that defines connection patterns
React Relay Hooks API: https://relay.dev/docs/api-reference/hooks/use-pagination-fragment/ - Complete reference for usePaginationFragment and related hooks
Congratulations! π You now understand how to build scalable, performant list interfaces with Relay. Whether you're building infinite scroll feeds, traditional paginated tables, or real-time chat histories, you have the tools to handle millions of items with grace. Keep practicing these patterns, and you'll build applications that feel fast no matter how large your data grows!