Steve Kinney

GraphQL & React Performance

GraphQL promises to solve over-fetching and under-fetching, but implement it carelessly in your React app and you’ll create performance problems that make REST look speedy. Waterfall requests, cache invalidation nightmares, bundle bloat from generated types, and the infamous N+1 query problem—GraphQL brings its own unique set of performance challenges that can tank your React application if you’re not careful.

But get it right, and GraphQL becomes a performance superpower. Fragment colocation ensures components only request the data they need. Normalized caching eliminates redundant network requests. Optimistic updates make your UI feel instant. And subscription-based real-time updates keep your app in sync without polling. This guide shows you how to leverage GraphQL’s strengths while avoiding its performance pitfalls in React applications.

Understanding GraphQL Performance Fundamentals

GraphQL’s performance characteristics differ fundamentally from REST:

// GraphQL performance model
interface GraphQLPerformanceModel {
  // Advantages
  advantages: {
    preciseDataFetching: 'Request exactly what you need';
    singleRequest: 'Multiple resources in one round trip';
    typeSystem: 'Compile-time optimization opportunities';
    caching: 'Normalized cache with automatic updates';
  };

  // Challenges
  challenges: {
    complexity: 'Query complexity can explode';
    bundleSize: 'Generated types and client libraries';
    caching: 'Cache invalidation complexity';
    n1Problem: 'Database queries can multiply';
  };

  // Key metrics
  metrics: {
    queryComplexity: number; // Computational cost
    responseSize: number; // Network payload
    resolverTime: number; // Server processing
    cacheHitRate: number; // Client cache efficiency
  };
}

// Example: REST vs GraphQL data fetching
// REST: Multiple requests, over-fetching
async function fetchUserWithREST(userId: string) {
  const user = await fetch(`/api/users/${userId}`);
  const posts = await fetch(`/api/users/${userId}/posts`);
  const comments = await fetch(`/api/users/${userId}/comments`);

  return {
    user: await user.json(), // All user fields
    posts: await posts.json(), // All post fields
    comments: await comments.json(), // All comment fields
  };
}

// GraphQL: Single request, precise data
const USER_QUERY = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      avatar
      posts(first: 10) {
        edges {
          node {
            id
            title
            commentCount
          }
        }
      }
    }
  }
`;

Fragment Colocation Pattern

Fragment colocation ensures components declare their own data requirements:

// ❌ Bad: Data requirements scattered
// UserProfile.tsx
function UserProfile({ userId }: { userId: string }) {
  const { data } = useQuery(
    gql`
      query GetUser($id: ID!) {
        user(id: $id) {
          id
          name
          email
          avatar
          bio
          followers
          following
          posts {
            id
            title
            content
            author {
              name
              avatar
            }
            comments {
              id
              content
              author {
                name
              }
            }
          }
        }
      }
    `,
    { variables: { id: userId } },
  );

  // Component doesn't need all this data!
  return <div>{data.user.name}</div>;
}

// ✅ Good: Colocated fragments
// UserAvatar.tsx
const UserAvatarFragment = gql`
  fragment UserAvatarFragment on User {
    id
    name
    avatar
  }
`;

function UserAvatar({ user }: { user: UserAvatarFragment }) {
  return (
    <div className="avatar">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
    </div>
  );
}

// PostList.tsx
const PostListFragment = gql`
  fragment PostListFragment on User {
    id
    posts(first: 10) {
      edges {
        node {
          ...PostItemFragment
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
  ${PostItemFragment}
`;

function PostList({ user }: { user: PostListFragment }) {
  return (
    <div className="post-list">
      {user.posts.edges.map(({ node }) => (
        <PostItem key={node.id} post={node} />
      ))}
    </div>
  );
}

// UserProfile.tsx - Compose fragments
const USER_PROFILE_QUERY = gql`
  query UserProfile($id: ID!) {
    user(id: $id) {
      ...UserAvatarFragment
      ...PostListFragment
      ...UserStatsFragment
    }
  }
  ${UserAvatarFragment}
  ${PostListFragment}
  ${UserStatsFragment}
`;

function UserProfile({ userId }: { userId: string }) {
  const { data, loading } = useQuery(USER_PROFILE_QUERY, {
    variables: { id: userId },
  });

  if (loading) return <ProfileSkeleton />;

  return (
    <div>
      <UserAvatar user={data.user} />
      <UserStats user={data.user} />
      <PostList user={data.user} />
    </div>
  );
}

Apollo Client Optimization

For comprehensive Apollo Client performance optimization including cache configuration, query batching, optimistic updates, and subscription management, see Apollo Client Performance Optimization.

Key Apollo Client topics covered:

  • Cache Configuration: Type policies, merge functions, and cache persistence
  • Query Batching: Reducing network requests with smart batching strategies
  • Optimistic Updates: Making mutations feel instant with optimistic responses
  • Subscription Management: Efficient real-time updates with WebSocket optimization

Query Complexity and Performance

Prevent expensive queries from killing performance:

// Query complexity analysis
interface ComplexityRule {
  scalarCost: number;
  objectCost: number;
  listFactor: number;
  introspectionCost: number;
  depthLimit: number;
  maxComplexity: number;
}

function calculateQueryComplexity(
  query: DocumentNode,
  variables: any,
  rules: ComplexityRule,
): number {
  let complexity = 0;
  let depth = 0;

  visit(query, {
    Field: {
      enter(node) {
        depth++;

        // Check depth limit
        if (depth > rules.depthLimit) {
          throw new Error(`Query depth ${depth} exceeds limit ${rules.depthLimit}`);
        }

        // Calculate field complexity
        const fieldName = node.name.value;
        const args = node.arguments || [];

        // List fields have multiplied complexity
        const limitArg = args.find(
          (arg) => arg.name.value === 'first' || arg.name.value === 'last',
        );
        const limit = limitArg ? variables[limitArg.value.value] || 10 : 1;

        if (node.selectionSet) {
          // Object type
          complexity += rules.objectCost * limit;
        } else {
          // Scalar type
          complexity += rules.scalarCost * limit;
        }

        // Introspection queries are expensive
        if (fieldName.startsWith('__')) {
          complexity += rules.introspectionCost;
        }
      },
      leave() {
        depth--;
      },
    },
  });

  if (complexity > rules.maxComplexity) {
    throw new Error(`Query complexity ${complexity} exceeds limit ${rules.maxComplexity}`);
  }

  return complexity;
}

// Use in Apollo Link
const complexityLink = new ApolloLink((operation, forward) => {
  try {
    const complexity = calculateQueryComplexity(operation.query, operation.variables, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
      introspectionCost: 100,
      depthLimit: 7,
      maxComplexity: 1000,
    });

    console.log(`Query ${operation.operationName} complexity: ${complexity}`);
  } catch (error) {
    console.error('Query too complex:', error);
    return Observable.of({
      data: null,
      errors: [{ message: error.message }],
    });
  }

  return forward(operation);
});

Lazy Query Loading

Load queries on demand:

// Lazy query pattern
function SearchResults() {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm] = useDebouncedValue(searchTerm, 300);

  // Don't execute until called
  const [search, { data, loading, error }] = useLazyQuery(SEARCH_QUERY, {
    fetchPolicy: 'cache-first',
    nextFetchPolicy: 'cache-and-network',
  });

  useEffect(() => {
    if (debouncedTerm.length >= 3) {
      search({
        variables: { query: debouncedTerm },
      });
    }
  }, [debouncedTerm, search]);

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />

      {loading && <Spinner />}
      {error && <Error error={error} />}
      {data && <Results results={data.search} />}
    </div>
  );
}

// Intersection observer for lazy loading
function useLazyQueryOnVisible<T>(query: DocumentNode, options?: QueryOptions<T>) {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
      threshold: 0.1,
    });

    if (ref.current) observer.observe(ref.current);

    return () => observer.disconnect();
  }, []);

  const queryResult = useQuery(query, {
    ...options,
    skip: !isVisible || options?.skip,
  });

  return { ref, ...queryResult };
}

// Usage
function LazySection() {
  const { ref, data, loading } = useLazyQueryOnVisible(EXPENSIVE_QUERY);

  return (
    <div ref={ref}>
      {loading && <Skeleton />}
      {data && <ExpensiveComponent data={data} />}
    </div>
  );
}

Performance Monitoring

Track GraphQL performance metrics:

// Performance monitoring link
const performanceLink = new ApolloLink((operation, forward) => {
  const startTime = performance.now();

  return forward(operation).map((response) => {
    const duration = performance.now() - startTime;

    // Log slow queries
    if (duration > 1000) {
      console.warn(`Slow query: ${operation.operationName} took ${duration}ms`);
    }

    // Send metrics to analytics
    if (window.gtag) {
      window.gtag('event', 'graphql_query', {
        event_category: 'Performance',
        event_label: operation.operationName,
        value: Math.round(duration),
        custom_map: {
          dimension1: operation.operationName,
          metric1: duration,
          metric2: JSON.stringify(response.data).length,
        },
      });
    }

    // Add to performance observer
    performance.mark(`graphql-${operation.operationName}-end`);
    performance.measure(
      `graphql-${operation.operationName}`,
      `graphql-${operation.operationName}-start`,
      `graphql-${operation.operationName}-end`,
    );

    return response;
  });
});

// Query performance hook
function useQueryPerformance() {
  const [metrics, setMetrics] = useState<Map<string, QueryMetrics>>(new Map());

  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name.startsWith('graphql-')) {
          const queryName = entry.name.replace('graphql-', '');
          setMetrics((prev) => {
            const updated = new Map(prev);
            const existing = updated.get(queryName) || {
              count: 0,
              totalTime: 0,
              avgTime: 0,
              minTime: Infinity,
              maxTime: 0,
            };

            updated.set(queryName, {
              count: existing.count + 1,
              totalTime: existing.totalTime + entry.duration,
              avgTime: (existing.totalTime + entry.duration) / (existing.count + 1),
              minTime: Math.min(existing.minTime, entry.duration),
              maxTime: Math.max(existing.maxTime, entry.duration),
            });

            return updated;
          });
        }
      }
    });

    observer.observe({ entryTypes: ['measure'] });

    return () => observer.disconnect();
  }, []);

  return metrics;
}

Bundle Size Optimization

Reduce GraphQL client bundle size:

// Minimal Apollo Client setup
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  gql,
} from '@apollo/client/core';

// Only import what you need
import { useQuery } from '@apollo/client/react/hooks/useQuery';
import { useMutation } from '@apollo/client/react/hooks/useMutation';

// Tree-shakeable imports
const client = new ApolloClient({
  link: createHttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});

// Use graphql-tag/loader to precompile queries
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(graphql|gql)$/,
        exclude: /node_modules/,
        loader: 'graphql-tag/loader',
      },
    ],
  },
};

// Import compiled queries
import USER_QUERY from './queries/user.graphql';

// Generate types without runtime overhead
// codegen.yml
overwrite: true
schema: "http://localhost:4000/graphql"
generates:
  src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
    config:
      skipTypename: true
      enumsAsTypes: true
      constEnums: true
      immutableTypes: true

Best Practices Checklist

interface GraphQLPerformanceBestPractices {
  // Query optimization
  queries: {
    useFragments: 'Colocate data requirements';
    limitFields: 'Only request needed fields';
    pagination: 'Use cursor-based pagination';
    depthLimit: 'Limit query depth to prevent abuse';
  };

  // Caching
  caching: {
    normalizeData: 'Configure proper type policies';
    persistCache: 'Use local storage for offline';
    updateCache: 'Manual cache updates after mutations';
    gcUnused: 'Garbage collect unused cache entries';
  };

  // Network
  network: {
    batchQueries: 'Batch multiple queries';
    deduplicateRequests: 'Prevent duplicate requests';
    compressResponses: 'Enable gzip compression';
    cdnCaching: 'Cache at CDN level when possible';
  };

  // Real-time
  realtime: {
    useWebSockets: 'WebSockets for subscriptions';
    throttleUpdates: 'Throttle high-frequency updates';
    smartSubscriptions: 'Subscribe only when visible';
    cleanupSubscriptions: 'Unsubscribe on unmount';
  };

  // Bundle
  bundle: {
    treeShake: 'Import only needed functions';
    precompileQueries: 'Compile queries at build time';
    lazyLoadClient: 'Code split GraphQL client';
    minimizeTypes: 'Generate minimal TypeScript types';
  };
}

Last modified on .