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:
- Network efficiency: Minimizing redundant requests through proper caching and deduplication.
- Render optimization: Preventing unnecessary component re-renders when query state changes.
- Memory management: Controlling cache size and garbage collection of inactive queries.
- Concurrency: Managing parallel and dependent queries efficiently.
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
;
}
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:
- Without selectors: A single data update triggers re-renders in all 50 subscribed components, averaging 120ms of render time.
- With selectors: Only 3 components re-render that depend on the changed field, averaging 8ms of render time.
- Without prefetching: Average perceived load time of 450ms per navigation.
- With prefetching on hover: Average perceived load time drops to 20ms, as data is already cached.
- Default gcTime (5 min): Cache holds approximately 12MB for 500 queries with moderate data payloads.
- Aggressive gcTime (30 sec): Cache drops to 3MB but increases refetch frequency by 40%.
Best Practices Summary
- Use
selectto subscribe only to the data slices each component needs. - Set appropriate
staleTimevalues per query rather than relying solely on defaults. - Prefetch data on user interactions like hover, focus, or route preloading.
- Use
useQueriesfor dynamic parallel queries to avoid waterfalls. - Implement optimistic updates for mutations to improve perceived performance.
- Invalidate queries with specific keys rather than broad invalidation patterns.
- Monitor cache size in development and adjust
gcTimebased on your data profile. - Use
placeholderData: keepPreviousDatafor smooth pagination transitions. - Avoid calling
setQueryDatawith new object references when only a field changed; let structural sharing handle it. - Profile re-renders during development to catch unnecessary updates early.
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.