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

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:

  1. Nested lazy queries: Parent fetches data, then child components start their own queries
  2. Conditional rendering: if (data) { return <Child /> } delays child query execution
  3. Route-based splitting: Each route loads data separately instead of preloading

How to prevent waterfalls:

  • Use useLazyLoadQuery at the route level (entry point)
  • Compose fragments from child components into parent queries
  • Pre-fetch data on route transitions (before component mounts)
  • Use @defer directive 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:

  1. Prevents over-fetching: Component only receives data it declares
  2. Enables precise updates: Relay knows exactly which components need re-rendering when data changes
  3. 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:

  1. Component A executes query β†’ Relay adds to batch queue
  2. Component B executes query β†’ Relay adds to same batch (within 10ms window)
  3. After 10ms, Relay sends one request with all queries
  4. Server executes queries in parallel, returns combined response
  5. 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:

  1. Refactor to single entry-point query using fragment composition
  2. Enable batching in network layer
  3. Use @defer for 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 id field
  • 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 metrics re-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 CPUWidget re-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:

  • @defer tells 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:

  1. Component mounts β†’ renders spinner
  2. Effect runs after render
  3. Query starts after effect
  4. 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:

  1. Parent knows about child data needs (tight coupling)
  2. Adding fields to PostsList requires editing Dashboard
  3. Over-fetching: If PostsList unmounts, still fetching posts
  4. Relay can't optimize re-rendersβ€”parent passes full user object

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:

  1. If a new post is added, offset shiftsβ€”might see duplicates
  2. Each page requires new queryβ€”can't use connection spec
  3. No way to "load more" without full re-fetch
  4. 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
  • @connection tells 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

  1. Always use fragment colocation: Never fetch data in parent that child components need
  2. Prefetch on intent signals: Hover, focus, route changeβ€”start loading before user commits
  3. Measure with Network tab: Count GraphQL requests. More than 1-2 per page load? Investigate waterfall
  4. Give changing data IDs: If it updates independently, it needs normalization
  5. Use @defer for progressive enhancement: Render critical content fast, stream in the rest
  6. 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 to useLazyLoadQuery

Fix the issues with the highest impact first (waterfalls, then over-fetching, then batching).


πŸ“š Further Study

  1. Relay Documentation - Performance Best Practices: https://relay.dev/docs/guides/performance-best-practices/ - Official guide covering advanced optimization techniques and measuring tools

  2. 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)

  3. 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.