Logo
Bason

TanStack Query: Why Did My App Get Slower After Adopting It? 6 Real-World Traps to Avoid

TanStack Query, 도입했는데 왜 더 느려졌을까: 실무 함정 6가지

이성훈
이성훈May 1, 2026 · 1 views

TanStack Query: Why Did My App Get Slower After Adopting It? 6 Real-World Traps to Avoid

Meta Description: Adopted TanStack Query but noticed slower performance? Discover 6 common real-world pitfalls that developers fall into and how to fix them fast.


You've just integrated TanStack Query into your React project. The docs looked clean, the community raved about it, and your team was pumped. Then something unexpected happened — your app felt slower. Pages that loaded instantly before now flash a loading spinner. Network requests seem to multiply. Your users are complaining.

Sound familiar? You're not alone.

TanStack Query (formerly React Query) is genuinely one of the most powerful data-fetching and state management libraries available today. With over 40,000+ GitHub stars and adoption by companies like Shopify, Microsoft, and Netflix, it's become a cornerstone of modern React development. But like any powerful tool, wielding it without understanding its internals can turn a performance dream into a production nightmare.

This post breaks down 6 real-world traps that developers — from junior engineers to seasoned architects — fall into when adopting TanStack Query. More importantly, we'll show you exactly how to get out of them.


1. Misunderstanding staleTime and gcTime: The "Always Refetching" Trap

Here's the trap that catches nearly everyone on day one.

By default, TanStack Query sets staleTime to 0. This means the moment a query fetches data, that data is immediately considered stale. Every time a component mounts, the window regains focus, or the network reconnects, TanStack Query will fire a fresh background refetch.

In theory, this keeps your data fresh. In practice, if you're not aware of it, you'll watch your Network tab explode with requests you never asked for.

What's Actually Happening

  • staleTime: How long data is considered "fresh." During this period, no refetch occurs.
  • gcTime (formerly cacheTime in v4): How long inactive query data stays in memory before being garbage collected. Default is 5 minutes.

The confusion between these two is the source of countless performance bugs.

The Fix

Set a sensible global default using QueryClient:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      gcTime: 1000 * 60 * 10,   // 10 minutes
    },
  },
});

Then override at the query level for data that genuinely needs real-time freshness. Think of staleTime as your first line of defense against unnecessary network chatter. Most UI data — user profiles, product listings, dashboard stats — doesn't need to be refetched every time a user switches tabs.

Pro tip (2024 trend): With TanStack Query v5, the team strongly recommends treating staleTime configuration as a first-class concern in your architecture, not an afterthought.


2. Query Key Mismanagement: The Silent Cache Buster

Query keys are the DNA of TanStack Query's caching system. Get them wrong, and you either blow up your cache on every render or — worse — serve users stale data that never updates.

The Common Mistakes

Mistake #1: Using inline objects or arrays without memoization

// ❌ This creates a new array reference on every render
useQuery({ queryKey: ['users', { page: currentPage, filter }], ... })

Wait — actually, TanStack Query serializes query keys, so object identity doesn't matter. But the next mistake is far more dangerous:

Mistake #2: Too-broad query keys that invalidate everything

// ❌ Invalidating everything when only one item changes
queryClient.invalidateQueries({ queryKey: ['users'] });

If you have paginated queries, filtered queries, and individual user queries all under the ['users'] namespace, one mutation invalidates all of them simultaneously — triggering a waterfall of network requests.

Mistake #3: Too-specific query keys that prevent cache reuse

// ❌ This creates a new cache entry every time timestamp changes
useQuery({ queryKey: ['product', productId, Date.now()], ... })

The Fix: Adopt a Query Key Factory Pattern

export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

This pattern — popularized by Dominik Dorfmeister (TkDodo), the most prominent community voice on TanStack Query — gives you surgical precision over cache invalidation. It's become the industry standard in 2024 for large-scale TanStack Query implementations.


3. Ignoring select for Computed Data: The Re-render Avalanche

This one is subtle and insidious. Your queries work fine, your cache is configured properly, but your components still re-render far more than they should.

The culprit? Transforming data inside your component instead of using TanStack Query's built-in select option.

The Problem

// ❌ Every time the query updates, the entire component re-renders
// even if the data you actually care about hasn't changed
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
const completedTodos = data?.filter(todo => todo.completed) ?? [];

When data updates — even if the completed todos are identical — React re-renders the component. With complex data structures, this cascades into child components, causing visual jank and degraded UX.

The Fix: Use select as Your Data Lens

// ✅ Component only re-renders when completedTodos actually changes
const { data: completedTodos } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (data) => data.filter(todo => todo.completed),
});

TanStack Query uses structural sharing to compare the previous and next selected values. If they're deeply equal, the component doesn't re-render. This is a massive, often overlooked performance optimization.

Taking It Further with select

// Combine with useMemo for complex transformations
const { data: todoStats } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: useCallback((data) => ({
    total: data.length,
    completed: data.filter(t => t.completed).length,
    pending: data.filter(t => !t.completed).length,
  }), []),
});

This pattern is especially powerful in 2024's trend toward micro-frontend and component-driven architectures, where controlling re-render boundaries is critical.


4. Waterfall Queries: The Sequential Loading Anti-Pattern

Here's a scenario: you build a user dashboard. First, you fetch the current user. Once you have the user, you fetch their organization. Once you have the org, you fetch its billing status. Each query depends on the previous one.

This is the request waterfall — and TanStack Query makes it dangerously easy to accidentally build one.

What a Waterfall Looks Like

Fetch user:          |--200ms--|
Fetch organization:            |--150ms--|
Fetch billing:                           |--180ms--|
Total:               |--------530ms---------|

Three sequential requests that could have been parallelized or combined into one.

The Fixes

Option 1: Use enabled wisely and pre-fetch at the route level

// Kick off all fetches as early as possible
// In your router (React Router, TanStack Router, Next.js)
export const loader = async () => {
  await queryClient.prefetchQuery(userOptions());
  // Prefetch dependent data if IDs are known
};

Option 2: Parallel queries with useQueries

// ✅ When queries are independent, run them in parallel
const results = useQueries({
  queries: [
    { queryKey: ['user', userId], queryFn: fetchUser },
    { queryKey: ['settings', userId], queryFn: fetchSettings },
    { queryKey: ['notifications', userId], queryFn: fetchNotifications },
  ],
});

Option 3: Combine with TanStack Router for zero-waterfall architecture

In 2024, the biggest performance win in the TanStack ecosystem is combining TanStack Query with TanStack Router. TanStack Router's loader system integrates natively with TanStack Query, allowing you to kick off all necessary queries before a component even renders.

// TanStack Router loader
export const Route = createFileRoute('/dashboard')({
  loader: ({ context: { queryClient } }) => 
    queryClient.ensureQueryData(dashboardOptions()),
  component: Dashboard,
});

The data is ready before the paint. No spinner. No waterfall. This is the modern gold standard.


5. Mutation Side Effects Done Wrong: The Stale Data Illusion

You've nailed your queries. Now your mutations are causing chaos. A user updates their profile, the mutation succeeds, but the UI still shows the old data. Or worse — it briefly shows the new data via optimistic updates, then snaps back to the old data after the refetch.

This is the stale data illusion, and it stems from poorly configured mutation side effects.

The Common Mistakes

Mistake #1: Not invalidating related queries after mutation

// ❌ Cache is never told about the update
const mutation = useMutation({
  mutationFn: updateUser,
});

Mistake #2: Invalidating too broadly (back to trap #2)

// ❌ Nukes everything, triggers a network storm
onSuccess: () => {
  queryClient.invalidateQueries();
}

Mistake #3: Optimistic updates without proper rollback

// ❌ No error handling = UI lies to users when mutations fail
onMutate: async (newUser) => {
  queryClient.setQueryData(['user', newUser.id], newUser);
  // Where's the rollback on error? 🔥
}

The Fix: The Complete Mutation Pattern

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newUser) => {
    // 1. Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) });
    
    // 2. Snapshot previous value
    const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id));
    
    // 3. Optimistically update
    queryClient.setQueryData(userKeys.detail(newUser.id), newUser);
    
    // 4. Return context for rollback
    return { previousUser };
  },
  onError: (err, newUser, context) => {
    // 5. Rollback on failure
    queryClient.setQueryData(userKeys.detail(newUser.id), context?.previousUser);
  },
  onSettled: (data, error, variables) => {
    // 6. Always sync with server truth
    queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
  },
});

This six-step pattern ensures your UI is always consistent, whether the mutation succeeds or fails. In 2024, optimistic UI is table stakes — users expect instant feedback. This pattern delivers it safely.


6. Skipping Suspense and Error Boundaries: The UX Debt Bomb

The final trap is architectural. As TanStack Query v5 doubles down on first-class Suspense support, developers who ignore it are accumulating serious UX debt — and writing significantly more boilerplate than necessary.

The Old Way (Still Technically Valid, But Painful)

const { data, isLoading, isError, error } = useQuery({ ... });

if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return <UserProfile data={data} />;

Multiply this pattern across 50 components and you have a maintenance nightmare. Loading and error states become inconsistent, and the mental overhead is enormous.

The Modern Way: Suspense + Error Boundaries

// Parent component
<ErrorBoundary fallback={<ErrorFallback />}>
  <Suspense fallback={<ProfileSkeleton />}>
    <UserProfile userId={userId} />
  </Suspense>
</ErrorBoundary>

// UserProfile component - clean, focused on happy path
function UserProfile({ userId }) {
  const { data: user } = useSuspenseQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
  });
  
  return <div>{user.name}</div>; // No loading/error checks needed
}

Why This Matters in 2024

React 19's concurrent features, combined with TanStack Query v5's useSuspenseQuery and useSuspenseInfiniteQuery, create a declarative async model that's both more performant (better streaming SSR) and more maintainable.

Additional benefits:

  • Consistent loading UX across your entire app
  • Error boundaries catch both query errors and render errors
  • Works beautifully with React Server Components in Next.js 14+
  • Enables streaming SSR for faster Time to First Byte (TTFB)

The 2024 recommendation from the TanStack team: treat useSuspenseQuery as your default for new code, falling back to the traditional isLoading/isError pattern only when you need granular control.


Conclusion: TanStack Query Is a Performance Multiplier — If You Respect It

TanStack Query didn't make your app slower. These six traps did.

Let's recap what you need to do:

  1. Set staleTime intentionally — stop the refetch avalanche before it starts
  2. Use a Query Key Factory — gain surgical cache control
  3. Use select for derived data — eliminate unnecessary re-renders
  4. Eliminate waterfalls — with useQueries, prefetching, and TanStack Router
  5. Write complete mutation patterns — with optimistic updates and rollbacks
  6. Adopt Suspense boundaries — for cleaner, more performant async UI

When you sidestep these traps, TanStack Query delivers on every promise: fewer network requests, smarter caching, instant UI updates, and a codebase that scales gracefully.


Ready to audit your TanStack Query implementation?

Start with trap #1. Open your browser's Network tab, navigate around your app, and count how many requests fire unexpectedly. If you see requests on window focus that don't need to be there, you've already found your first quick win.

Share this post with your team, bookmark it for your next code review, and drop a comment below — which of these traps hit you hardest? Let's compare war stories. 👇


Last updated for TanStack Query v5 and React 19 compatibility. For the latest API changes, always reference the official TanStack Query docs.

Contact support