โ† Back to DevBytes

TanStack Query Performance: Optimization Techniques and Benchmarks

Introduction to TanStack Query Performance

TanStack Query (formerly React Query) has become the de facto standard for data fetching and state management in modern web applications. While the library is designed to be performant out of the box, real-world applications often face performance bottlenecks when dealing with large datasets, complex query dependencies, or high-frequency updates. This tutorial explores practical optimization techniques and benchmarks to help you squeeze maximum performance from TanStack Query.

Why Performance Matters

Performance in TanStack Query is not just about raw speed. It encompasses memory usage, network efficiency, render optimization, and user experience. Poorly configured queries can lead to unnecessary re-renders, redundant network requests, and bloated memory consumption. In applications with hundreds of components subscribing to queries, even small inefficiencies compound into noticeable lag.

The key performance dimensions to consider include:

Understanding the Query Cache

The query cache is the heart of TanStack Query's performance model. Every query result is stored in an in-memory cache keyed by the query key. Understanding how the cache works is essential for optimization.

Cache Configuration

The QueryClient accepts default options that govern cache behavior globally. Tuning these values based on your application's data freshness requirements can dramatically reduce network traffic.

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute before data is considered stale
      gcTime: 5 * 60 * 1000, // 5 minutes before inactive queries are garbage collected
      refetchOnWindowFocus: false,
      retry: 1,
      refetchOnReconnect: 'always',
    },
  },
});

The staleTime determines how long data remains fresh before TanStack Query will refetch it. Setting this too low causes excessive refetching; setting it too high risks serving outdated data. The gcTime (formerly cacheTime) controls how long inactive queries stay in memory before being garbage collected.

Selective Cache Invalidation

Broad invalidation patterns like invalidateQueries() with no arguments can trigger mass refetches. Instead, target specific query keys.

// Avoid: invalidates everything
await queryClient.invalidateQueries();

// Better: invalidate a specific domain
await queryClient.invalidateQueries({ queryKey: ['users'] });

// Best: invalidate a specific entity
await queryClient.invalidateQueries({ 
  queryKey: ['users', userId] 
});

Optimizing Re-renders with Selectors

One of the most impactful optimizations is using the select option to subscribe only to the slice of data a component needs. Without selectors, any change to the query data triggers a re-render in every subscribed component, even if the changed field is irrelevant.

function UserAvatar({ userId }) {
  const { data } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    select: (user) => ({ 
      avatarUrl: user.avatarUrl, 
      name: user.name 
    }),
  });

  if (!data) return null;
  return {data.name};
}

With select, the component only re-renders when the selected fields change. TanStack Query performs a structural comparison between previous and next selected values using structuralSharing by default, skipping re-renders when the output is identical.

Custom Equality Functions

For more control, you can disable structural sharing and provide a custom comparison function.

const { data } = useQuery({
  queryKey: ['metrics'],
  queryFn: fetchMetrics,
  structuralSharing: false,
  select: (data) => data.filter(m => m.active),
});

Parallel and Dependent Queries

TanStack Query handles parallel queries automatically when multiple useQuery hooks are called in the same component. However, for dynamic parallel queries, use useQueries to avoid waterfall effects.

import { useQueries } from '@tanstack/react-query';

function UserDashboard({ userIds }) {
  const results = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['users', id],
      queryFn: () => fetchUser(id),
      staleTime: 60 * 1000,
    })),
  });

  const isLoading = results.some((r) => r.isLoading);
  if (isLoading) return ;

  return (
    
    {results.map((r, i) => (
  • {r.data?.name}
  • ))}
); }

For dependent queries where one query depends on the result of another, use the enabled option to prevent premature execution.

const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
});

const { data: projects } = useQuery({
  queryKey: ['projects', user?.teamId],
  queryFn: () => fetchProjects(user.teamId),
  enabled: !!user?.teamId,
});

Pagination and Infinite Queries

For large datasets, loading everything at once is wasteful. TanStack Query provides useInfiniteQuery for cursor-based pagination and standard useQuery with placeholderData for page-based pagination.

import { useInfiniteQuery } from '@tanstack/react-query';

function ProductList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam = 0 }) => fetchProducts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialPageParam: 0,
  });

  return (
    
{data.pages.map((page, i) => ( ))}
); }

Pagination with Placeholder Data

For page-based pagination, use keepPreviousData behavior via placeholderData to maintain a smooth UX while fetching the next page.

import { keepPreviousData } from '@tanstack/react-query';

function PaginatedList({ page }) {
  const { data, isFetching } = useQuery({
    queryKey: ['items', page],
    queryFn: () => fetchItems(page),
    placeholderData: keepPreviousData,
  });

  return (
    
{data?.items.map(item => )}
); }

Prefetching Strategies

Prefetching is one of the most effective techniques for perceived performance. By loading data before the user needs it, you eliminate loading states entirely. The queryClient.prefetchQuery method populates the cache without subscribing a component.

function ProductLink({ productId }) {
  const queryClient = useQueryClient();

  const handleHover = () => {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProduct(productId),
      staleTime: 60 * 1000,
    });
  };

  return (
    
      View Product
    
  );
}

You can also prefetch on route change or during idle periods using the requestIdleCallback API.

function prefetchOnIdle(queryClient) {
  requestIdleCallback(() => {
    queryClient.prefetchQuery({
      queryKey: ['dashboard', 'summary'],
      queryFn: fetchDashboardSummary,
      staleTime: 5 * 60 * 1000,
    });
  });
}

Mutations and Optimistic Updates

Optimistic updates improve perceived performance by updating the UI immediately before the server confirms the change. TanStack Query's onMutate callback lets you implement this pattern cleanly.

const updateTodoMutation = useMutation({
  mutationFn: (newTodo) => updateTodoAPI(newTodo),
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // Snapshot previous value
    const previousTodos = queryClient.getQueryData(['todos']);

    // Optimistically update cache
    queryClient.setQueryData(['todos'], (old) => 
      old.map(todo => todo.id === newTodo.id ? newTodo : todo)
    );

    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Memory Management

In long-running applications, the query cache can grow unbounded if not managed. While gcTime handles automatic cleanup of inactive queries, you may need manual intervention for specific scenarios.

Removing Specific Queries

// Remove a single query
queryClient.removeQueries({ queryKey: ['temp-data'] });

// Remove all inactive queries
queryClient.removeQueries({ type: 'inactive' });

// Clear entire cache on logout
queryClient.clear();

Monitoring Cache Size

You can inspect the cache programmatically to identify memory issues during development.

function logCacheStats() {
  const cache = queryClient.getQueryCache();
  const queries = cache.getAll();
  
  console.log(`Total queries in cache: ${queries.length}`);
  console.log(`Active queries: ${queries.filter(q => q.observers.length > 0).length}`);
  console.log(`Inactive queries: ${queries.filter(q => q.observers.length === 0).length}`);
  
  // Estimate memory usage
  const totalSize = JSON.stringify(queries.map(q => q.state.data)).length;
  console.log(`Approximate cache size: ${(totalSize / 1024).toFixed(2)} KB`);
}

Benchmarks and Measurement

Optimization without measurement is guesswork. TanStack Query provides built-in devtools and query observers that help you track performance. For rigorous benchmarking, combine these with browser profiling tools.

Using Query Cache Observers

const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
  if (event.type === 'updated') {
    const query = event.query;
    console.log({
      queryKey: query.queryKey,
      isStale: query.isStale(),
      dataUpdatedAt: query.state.dataUpdatedAt,
      observers: query.observers.length,
    });
  }
});

// Cleanup when done
unsubscribe();

Measuring Render Performance

Wrap your components with a profiling utility to count re-renders triggered by query updates.

import { useRef } from 'react';

function withRenderCount(WrappedComponent, label) {
  return function ProfilerComponent(props) {
    const renderCount = useRef(0);
    renderCount.current++;
    console.log(`${label} rendered ${renderCount.current} times`);
    return ;
  };
}

const OptimizedUserCard = withRenderCount(UserCard, 'UserCard');

Benchmark Results

In typical benchmarks, the following patterns emerge. These numbers are illustrative based on common application profiles with 500 active queries:

Best Practices Summary

Conclusion

TanStack Query provides a powerful caching and synchronization layer, but its performance depends heavily on how you configure and use it. By applying selectors to minimize re-renders, prefetching to eliminate loading states, managing cache lifecycle thoughtfully, and measuring with devtools and custom profilers, you can build applications that feel instant even with complex data requirements. The techniques in this tutorial are not one-time fixes but ongoing practices to revisit as your application scales. Start by identifying your most-rendered components and highest-traffic queries, apply targeted optimizations there first, and expand from there with measurement guiding every decision.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles