Performance Mental Models
Understanding when and why Relay re-renders components
Performance Mental Models for Relay
Master Relay performance optimization with free flashcards and proven mental frameworks. This lesson covers cache behavior patterns, query batching strategies, network waterfall prevention, and component rendering optimizationβessential concepts for building lightning-fast GraphQL applications at scale.
π» Welcome to Performance Mental Models
Performance optimization in Relay isn't about memorizing APIsβit's about developing the right mental models. When you understand how data flows through Relay's architecture, performance problems become predictable and solutions become obvious. This lesson equips you with the cognitive frameworks senior engineers use to diagnose bottlenecks and architect efficient data-fetching patterns.
Core Concepts: The Foundation of Relay Performance
π― Mental Model #1: The Store as a Normalized Graph Database
Think of Relay's store not as a cache, but as an in-memory graph database with global object identification.
Every object in your Relay store has a globally unique ID. When you fetch User:123 in one component and User:123 in another, Relay recognizes these as the same node in the graph. This normalization is the foundation of Relay's performance.
RELAY STORE STRUCTURE
βββββββββββββββββββββββββββββββββββββββββββ
β NORMALIZED STORE (by ID) β
βββββββββββββββββββββββββββββββββββββββββββ€
β β
β User:123 β { name: "Alice", β
β email: "alice@...", β
β posts: [Post:1, Post:2] } β
β β
β Post:1 β { title: "...", β
β author: User:123 } β
β β
β Post:2 β { title: "...", β
β author: User:123 } β
β β
βββββββββββββββββββββββββββββββββββββββββββ
β
β Single source of truth
β Update User:123 once β reflects everywhere
π‘ Key insight: When you update User:123 anywhere in your app, all components reading that user automatically see the change. You don't need to "invalidate caches" or "refresh queries"βRelay's graph normalization handles it.
Mental shortcut: "One ID, one record, everywhere consistent."
β‘ Mental Model #2: Query Execution as a Three-Phase Pipeline
Relay queries move through three distinct phases. Understanding these phases helps you identify where performance problems occur:
QUERY EXECUTION PIPELINE
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β PHASE 1 β β β PHASE 2 β β β PHASE 3 β
β Store Read β β Network β β Store Write β
β β β Fetch β β + Notify β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β β β
Check cache Fetch missing Update store
Return if data from Trigger React
complete GraphQL API re-renders
~0-1ms ~50-500ms ~1-10ms
π Fast β οΈ Slowest π Fast
Phase 1 - Store Read (Cache Check):
- Relay reads your query against the local store
- If all data is present β returns immediately (render from cache)
- If data is missing/stale β proceeds to Phase 2
- Performance win: Pre-fetching populates the store, making Phase 2 unnecessary
Phase 2 - Network Fetch (The Bottleneck):
- Relay sends a GraphQL request to your server
- This is almost always your slowest phase (network latency + server processing)
- Performance win: Batching multiple queries into one request reduces round trips
Phase 3 - Store Write & Notify (Update):
- Relay normalizes response data and writes to the store
- All subscribed components receive notifications
- React re-renders affected components
- Performance win: Fragment colocation ensures components only re-render when their data changes
π§ Mnemonic: "Read, Fetch, Write" (RFW) - the three-step dance every query performs.
π Mental Model #3: The Waterfall Anti-Pattern
The waterfall is the most common performance killer in Relay applications. It occurs when queries execute sequentially instead of in parallel.
WATERFALL (Sequential - β SLOW)
Component A renders
|
ββ Query A starts βββββββββββββΆ [200ms]
β β
ββ Component B renders (child)
|
ββ Query B starts βββββββββββββΆ [200ms]
β β
ββ Component C renders (child)
|
ββ Query C starts βββββββββββββΆ [200ms]
β
TOTAL: 600ms
PARALLEL (All at once - β
FAST)
Component tree renders
|
ββ Query A ββββββββββΆ [200ms]
ββ Query B ββββββββββΆ [200ms] } All execute
ββ Query C ββββββββββΆ [200ms] } simultaneously
β
TOTAL: 200ms
Why waterfalls happen:
- Nested lazy queries: Parent fetches data, then child components start their own queries
- Conditional rendering:
if (data) { return <Child /> }delays child query execution - Route-based splitting: Each route loads data separately instead of preloading
How to prevent waterfalls:
- Use
useLazyLoadQueryat the route level (entry point) - Compose fragments from child components into parent queries
- Pre-fetch data on route transitions (before component mounts)
- Use
@deferdirective for non-critical data that can stream in later
π‘ Mental model: "Declare all data needs upfront, fetch once, distribute down."
π Mental Model #4: Fragment Colocation as Component Dependency Management
Think of fragments as explicit data dependencies for components, similar to import statements for code dependencies.
| Code Dependencies | Data Dependencies |
|---|---|
import Button from './Button' |
fragment UserCard_user on User |
| Declares: "I need Button module" | Declares: "I need user name, avatar" |
| Bundler resolves dependencies | Relay compiler resolves fragments |
| Missing import β build error | Missing fragment β type error |
Why colocation matters for performance:
- Prevents over-fetching: Component only receives data it declares
- Enables precise updates: Relay knows exactly which components need re-rendering when data changes
- Makes prefetching accurate: When preloading a route, Relay fetches exactly what that route's components need
Example: Fragment Colocation Pattern
// UserCard.jsx
import { graphql, useFragment } from 'react-relay';
function UserCard({ userRef }) {
const user = useFragment(
graphql`
fragment UserCard_user on User {
name
avatarUrl
reputation
}
`,
userRef
);
return (
<div>
<img src={user.avatarUrl} />
<h3>{user.name}</h3>
<span>{user.reputation} points</span>
</div>
);
}
// Parent component
function UserList() {
const data = useLazyLoadQuery(
graphql`
query UserListQuery {
users {
id
...UserCard_user # Composes child's data needs
}
}
`
);
return data.users.map(user => (
<UserCard key={user.id} userRef={user} />
));
}
π― Performance benefit: If UserCard needs additional data later (e.g., bio), you only add it to the fragment. The parent query automatically includes it. No risk of forgetting to update the parent query.
π¦ Mental Model #5: Query Batching as Request Compression
When multiple components trigger queries simultaneously, Relay can batch them into a single HTTP request. Think of it like zip compression for network requests.
WITHOUT BATCHING WITH BATCHING [Query A] βββββββΆ [Query A ] [Query B] βββββββΆ vs. [Query B ] βββββββΆ [Query C] βββββββΆ [Query C ] 3 HTTP requests 1 HTTP request 3 Γ TCP handshake 1 Γ TCP handshake 3 Γ Server processing 1 Γ Server processing (parallel) Header overhead Γ 3 Header overhead Γ 1
Batching configuration (network layer setup):
import { Network } from 'relay-runtime';
import { createBatchMiddleware } from 'relay-batch-middleware';
const fetchQuery = createBatchMiddleware({
batchWait: 10, // Wait 10ms to collect queries
});
const network = Network.create(fetchQuery);
How batching works:
- Component A executes query β Relay adds to batch queue
- Component B executes query β Relay adds to same batch (within 10ms window)
- After 10ms, Relay sends one request with all queries
- Server executes queries in parallel, returns combined response
- Relay distributes results to respective components
π‘ Mental model: "Collect, combine, send once."
β οΈ Batching tradeoff: Small delay (10ms) to collect queries, but massive savings on network overhead. Almost always worth it.
Real-World Examples: Applying Mental Models
Example 1: Diagnosing a Slow Page Load
Scenario: Your dashboard takes 3 seconds to load, but your API responds in 300ms.
Investigation using mental models:
| Observation | Mental Model Applied | Diagnosis |
|---|---|---|
| Network tab shows 5 separate GraphQL requests | Waterfall Anti-Pattern | Queries executing sequentially |
| Requests happen 500ms apart | Three-Phase Pipeline | Child components waiting for parent data (Phase 3) before starting their Phase 1 |
| Each request is small (~2KB) | Query Batching | Batching not enabledβwasting overhead on multiple TCP connections |
Solution:
- Refactor to single entry-point query using fragment composition
- Enable batching in network layer
- Use
@deferfor below-the-fold content
Result: Load time drops to 400ms (300ms API + 100ms client processing)
Example 2: Optimizing a Real-Time Dashboard
Scenario: Dashboard shows live metrics. Every update causes the entire page to re-render.
Problem through the lens of normalization:
// β BAD: Querying metrics without IDs
query DashboardQuery {
metrics {
cpuUsage # No ID! Relay can't normalize
memoryUsage # Stored as "client:root:metrics"
diskUsage # Entire object must update together
}
}
// Components using this data
function CPUWidget({ metrics }) {
return <div>CPU: {metrics.cpuUsage}%</div>;
}
function MemoryWidget({ metrics }) {
return <div>Memory: {metrics.memoryUsage}%</div>;
}
Why this is slow:
- Metrics object lacks an
idfield - Relay stores it as a single blob under
client:root:metrics - When any metric updates, Relay sees the whole object as changed
- All components using
metricsre-render, even if their specific field didn't change
Solution using normalization mental model:
// β
GOOD: Each metric is a separate node
query DashboardQuery {
cpuMetric: metric(type: CPU) {
id # Now Relay can normalize!
value # Stored as Metric:cpu
}
memoryMetric: metric(type: MEMORY) {
id # Stored as Metric:memory
value
}
diskMetric: metric(type: DISK) {
id # Stored as Metric:disk
value
}
}
// Components using fragments
function CPUWidget({ metricRef }) {
const metric = useFragment(
graphql`
fragment CPUWidget_metric on Metric {
value
}
`,
metricRef
);
return <div>CPU: {metric.value}%</div>;
}
Performance improvement:
- Each metric is normalized separately in the store
- When CPU updates, only
CPUWidgetre-renders - Memory and disk widgets remain untouched
- Result: 3Γ fewer renders, smoother UI updates
π― Key lesson: "If it changes independently, give it an ID."
Example 3: Prefetching on Hover for Instant Navigation
Scenario: User hovers over a profile link. You want instant load when they click.
Using the Three-Phase Pipeline mental model:
import { useQueryLoader } from 'react-relay';
function UserLink({ userId }) {
const [queryRef, loadQuery] = useQueryLoader(UserProfileQuery);
return (
<Link
to={`/user/${userId}`}
onMouseEnter={() => {
// Start Phase 1 & 2 NOW (before click)
loadQuery({ userId });
}}
>
View Profile
</Link>
);
}
// When user clicks and navigates:
function UserProfile() {
// Phase 1 & 2 already complete!
// Data likely in store β instant render
const data = usePreloadedQuery(UserProfileQuery, queryRef);
return <div>{data.user.name}</div>;
}
Timeline comparison:
WITHOUT PREFETCH:
Hover β Click β Navigate β Phase 1 β Phase 2 β Render
(0ms) (300ms) (5ms)
Total: 305ms perceived load
WITH PREFETCH:
Hover β Phase 1 β Phase 2 β Click β Navigate β Render
(0ms) (300ms) (5ms)
Total: 5ms perceived load!
π‘ Perceived performance: By starting Phase 2 (network) during hover, the click feels instant. The 300ms happens "for free" during user's hand movement toward the link.
Example 4: Pagination Without Loading Spinners
Scenario: User clicks "Load More" on a list. You want new items to appear seamlessly without showing a loading state.
Using @defer with mental model understanding:
query FeedQuery {
posts(first: 10) @defer {
edges {
node {
id
title
...PostCard_post
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
function Feed() {
const { data } = useLazyLoadQuery(FeedQuery);
// Initial posts render immediately from cache
// @defer causes Relay to:
// 1. Return cached data instantly (optimistic render)
// 2. Fetch fresh data in background
// 3. Update seamlessly when arrives
return (
<div>
{data.posts.edges.map(edge => (
<PostCard key={edge.node.id} postRef={edge.node} />
))}
<LoadMoreButton />
</div>
);
}
Why this works:
@defertells Relay: "Don't block rendering on this field"- Relay returns cached data immediately (Phase 1 complete β render)
- Network fetch happens in background (Phase 2)
- When fresh data arrives, Relay updates seamlessly (Phase 3)
- User never sees spinnerβjust smooth content updates
π― Mental model: "Render fast, update silently."
Common Mistakes and How to Avoid Them
β οΈ Mistake #1: Fetching in Effects
The problem:
// β Anti-pattern: Fetching in useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchQuery(UserQuery, { userId }).then(setUser);
}, [userId]);
if (!user) return <Spinner />;
return <div>{user.name}</div>;
}
Why it's slow:
- Component mounts β renders spinner
- Effect runs after render
- Query starts after effect
- Total delay: React commit phase + effect execution + network
The fix using mental models:
// β
Correct: Declare data needs upfront
function UserProfile({ userId }) {
const data = useLazyLoadQuery(
graphql`
query UserProfileQuery($userId: ID!) {
user(id: $userId) {
name
email
}
}
`,
{ userId }
);
return <div>{data.user.name}</div>;
}
Why it's fast: Query starts during render (not after), saving one full render cycle.
β οΈ Mistake #2: Not Using Fragment Colocation
The problem:
// β Parent fetches everything
query DashboardQuery {
user {
name
email
avatar
posts { title, date } # Child component data
friends { name } # Another child's data
}
}
function Dashboard() {
const data = useLazyLoadQuery(DashboardQuery);
return (
<div>
<UserHeader user={data.user} />
<PostsList posts={data.user.posts} />
<FriendsList friends={data.user.friends} />
</div>
);
}
Problems:
- Parent knows about child data needs (tight coupling)
- Adding fields to
PostsListrequires editingDashboard - Over-fetching: If
PostsListunmounts, still fetching posts - Relay can't optimize re-rendersβparent passes full
userobject
The fix:
// β
Each component declares its needs
function UserHeader({ userRef }) {
const user = useFragment(
graphql`fragment UserHeader_user on User { name, avatar }`,
userRef
);
return <header><img src={user.avatar} /> {user.name}</header>;
}
function PostsList({ userRef }) {
const user = useFragment(
graphql`fragment PostsList_user on User { posts { title, date } }`,
userRef
);
return user.posts.map(post => <Post key={post.title} {...post} />);
}
function Dashboard() {
const data = useLazyLoadQuery(
graphql`
query DashboardQuery {
user {
...UserHeader_user
...PostsList_user
}
}
`
);
return (
<div>
<UserHeader userRef={data.user} />
<PostsList userRef={data.user} />
</div>
);
}
β οΈ Mistake #3: Ignoring Pagination Cursors
The problem: Fetching "next page" by incrementing offset:
// β Offset-based pagination
function Feed() {
const [page, setPage] = useState(0);
const data = useLazyLoadQuery(
graphql`query FeedQuery($offset: Int!) {
posts(offset: $offset, limit: 10) { id, title }
}`,
{ offset: page * 10 }
);
return (
<div>
{data.posts.map(post => <Post key={post.id} {...post} />)}
<button onClick={() => setPage(page + 1)}>Load More</button>
</div>
);
}
Why it's problematic:
- If a new post is added, offset shiftsβmight see duplicates
- Each page requires new queryβcan't use connection spec
- No way to "load more" without full re-fetch
- Store can't merge resultsβreplaces old data
The fix using Relay connections:
// β
Cursor-based pagination
function Feed() {
const { data, loadNext, hasNext } = usePaginationFragment(
graphql`
fragment Feed_query on Query
@refetchable(queryName: "FeedPaginationQuery") {
posts(first: $count, after: $cursor)
@connection(key: "Feed_posts") {
edges {
node {
id
title
}
}
}
}
`,
queryRef
);
return (
<div>
{data.posts.edges.map(edge => (
<Post key={edge.node.id} {...edge.node} />
))}
{hasNext && (
<button onClick={() => loadNext(10)}>Load More</button>
)}
</div>
);
}
Benefits:
- Cursor ensures consistent results even if data changes
@connectiontells Relay to merge new pages into store- Old posts remain visibleβseamless append
- Relay manages pagination state automatically
β οΈ Mistake #4: Not Splitting Code and Data Loading
The problem: Loading route component and data sequentially:
// β Load component, then data
const UserProfile = lazy(() => import('./UserProfile'));
function App() {
return (
<Routes>
<Route path="/user/:id" element={
<Suspense fallback={<Spinner />}>
<UserProfile /> {/* Loads JS, then UserProfile starts query */}
</Suspense>
} />
</Routes>
);
}
Timeline:
Navigate β Load JS (200ms) β Query starts β Fetch (300ms) β Render
Total: 500ms
The fix: Preload data and code in parallel:
// β
Preload both simultaneously
import { loadQuery } from 'react-relay';
const routes = [
{
path: '/user/:id',
component: lazy(() => import('./UserProfile')),
query: UserProfileQuery,
prepare: ({ params }) => ({
component: import('./UserProfile'), // Load JS
query: loadQuery(UserProfileQuery, { userId: params.id }) // Load data
})
}
];
// In router
function Router() {
const handleNavigation = (route, params) => {
const { component, query } = route.prepare({ params });
// Both load in parallel!
};
}
Timeline:
Navigate β Load JS (200ms)
ββ Query starts β Fetch (300ms)
Total: 300ms (JS loads "for free" during fetch)
π‘ Mental model: "Load code and data as siblings, not parent-child."
Key Takeaways
π Quick Reference: Mental Models Cheat Sheet
| Mental Model | Key Principle | Performance Impact |
|---|---|---|
| Store as Graph DB | One ID β one object β everywhere consistent | Eliminates redundant fetches, automatic updates |
| Three-Phase Pipeline | Read β Fetch β Write (identify the bottleneck) | Target optimization at slowest phase (usually network) |
| Waterfall Prevention | Declare all data needs upfront, fetch once | Parallel queries: 3Γ 200ms = 200ms, not 600ms |
| Fragment Colocation | Components declare dependencies like imports | Precise re-renders, accurate prefetching |
| Query Batching | Collect queries, send once | Reduces TCP overhead, server processes in parallel |
π― Actionable Principles
- Always use fragment colocation: Never fetch data in parent that child components need
- Prefetch on intent signals: Hover, focus, route changeβstart loading before user commits
- Measure with Network tab: Count GraphQL requests. More than 1-2 per page load? Investigate waterfall
- Give changing data IDs: If it updates independently, it needs normalization
- Use
@deferfor progressive enhancement: Render critical content fast, stream in the rest - Enable batching: Almost always a free performance win
π€ Did You Know?
Relay's normalization approach was inspired by Facebook's News Feed, where the same post could appear in multiple locations (timeline, group, search results). Without normalization, updating a post's like count would require finding and updating dozens of cache entries. With normalization, one write updates everywhereβthe mental model that powers apps serving billions of users.
π§ Try This: Performance Audit Checklist
For your next Relay project, check these indicators:
- Open DevTools Network tab, filter to GraphQL
- Navigate between routesβdo you see more than 2 requests?
- If yes, identify which component triggers each query
- Check if child queries wait for parent data (waterfall)
- Look at response sizesβfetching unused fields?
- Enable React DevTools Profilerβwhich components re-render on updates?
- Do unrelated components re-render? Missing fragment colocation
- Check for queries in
useEffectβmove touseLazyLoadQuery
Fix the issues with the highest impact first (waterfalls, then over-fetching, then batching).
π Further Study
Relay Documentation - Performance Best Practices: https://relay.dev/docs/guides/performance-best-practices/ - Official guide covering advanced optimization techniques and measuring tools
GraphQL Query Batching Deep Dive: https://www.apollographql.com/blog/apollo-client/performance/query-batching/ - Detailed explanation of batching algorithms and configuration options (Apollo-focused but concepts apply to Relay)
Relay Compiler Architecture: https://relay.dev/docs/guides/compiler/ - Understanding how Relay's compiler enables fragment colocation and static analysis for performance
Congratulations! You've mastered the mental models that separate good Relay developers from great ones. These frameworksβnormalization, pipeline phases, waterfall prevention, colocation, and batchingβwill guide your architectural decisions and make performance optimization intuitive rather than trial-and-error. Practice applying these models to your current codebase, and you'll develop the instinct to spot bottlenecks before they become problems.