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

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

Kaen
KaenMay 1, 2026 · 3 views
TanStack Query: Why Did My App Get Slower After Adopting It? 6 Real-World Traps to Avoid

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

Meta Description: Adopted TanStack Query but your app feels slower? Discover 6 real-world performance traps developers fall into and exactly how to fix them fast.


You did everything right.

You read the docs. You watched the tutorials. You replaced your tangled useEffect-plus-useState data-fetching logic with the elegant simplicity of TanStack Query. Your codebase looked cleaner, your team was happy, and you shipped it to production feeling like a senior dev who finally cracked the code.

Then the performance reports came in.

Slower page loads. Unnecessary network waterfalls. Users complaining about flickering spinners on screens that used to feel instant. And now you're sitting there wondering if TanStack Query — one of the most celebrated data-fetching libraries in the React ecosystem — somehow made your app worse.

Here's the hard truth: TanStack Query didn't slow your app down. The way it was configured did.

With over 2 million weekly npm downloads and adoption across companies like Netflix, Shopify, and countless SaaS startups, TanStack Query (formerly React Query) is battle-tested at scale. But it comes with sensible defaults that are designed for correctness, not raw speed. And if you don't understand what's happening under the hood, those defaults will quietly destroy your performance metrics.

In this post, we're going to walk through the 6 most common real-world traps developers fall into when adopting TanStack Query — and exactly how to escape each one.


Trap #1: You're Ignoring staleTime and Letting Everything Refetch Constantly

This is the single most common performance mistake, and it catches nearly every developer at least once.

By default, TanStack Query sets staleTime to 0. That means every single query is considered stale the moment it resolves. The data is cached, yes — but the instant any of these conditions are met, a background refetch fires:

  • The component mounts
  • The browser window is refocused
  • The network reconnects
  • A new component subscribes to the same query

Imagine a dashboard where multiple components share the same /api/user query. Every time a user clicks between tabs, switches to another app and comes back, or simply navigates, TanStack Query is firing network requests. Your server is hammered. Your users see loading flickers. And you're scratching your head wondering why.

The Fix: Set a meaningful staleTime based on how frequently your data actually changes.

// Global defaults — set this in your QueryClient
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
    },
  },
});

// Or per-query for granular control
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 1000 * 60 * 10, // 10 minutes for user data
});

A good mental model: staleTime is your promise to the user that the data is fresh enough to trust. User profile data? Maybe 10 minutes. Stock prices? Zero. A product catalog? Potentially hours. Tune it to your actual data freshness requirements.

Bonus: Don't Confuse staleTime with gcTime

As of TanStack Query v5, what was previously called cacheTime is now gcTime (garbage collection time). gcTime controls how long unused cached data stays in memory before being deleted. They're different levers:

  • staleTime = "Should I refetch this?"
  • gcTime = "Should I delete this from memory?"

Getting these mixed up leads to either memory bloat or missing cache entirely.


Trap #2: You're Creating a New QueryClient on Every Render

This one is subtle but devastating. It often appears in apps where the QueryClientProvider setup wasn't done carefully — particularly in Next.js App Router setups or during rushed refactors.

Here's what the broken pattern looks like:

// ❌ DON'T DO THIS
function App() {
  return (
    <QueryClientProvider client={new QueryClient()}>
      <YourApp />
    </QueryClientProvider>
  );
}

Every time App re-renders, a brand-new QueryClient is created. That means your entire cache is wiped. Every query refetches from scratch. Background refetches never stabilize. Your app is essentially incapable of caching anything.

The Fix: Instantiate QueryClient outside of the component, or use useState/useRef to keep it stable.

// ✅ CORRECT — outside the component
const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

// ✅ ALSO CORRECT — for SSR/Next.js App Router
function App() {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

In Next.js 14/15 with the App Router, the TanStack Query team specifically recommends the useState pattern with a factory function to avoid sharing state between users during server rendering. Follow the official SSR guide closely — it was significantly updated in 2024.


Trap #3: You're Triggering Query Waterfalls With Dependent Queries

Waterfalls are the silent killers of perceived performance. A waterfall happens when queries are chained sequentially — each one waiting for the previous to complete before firing — when they could have been parallelized.

Here's a classic example:

// ❌ Waterfall pattern
const { data: user } = useQuery(['user'], fetchUser);
const { data: posts } = useQuery(['posts', user?.id], fetchPosts, {
  enabled: !!user?.id,
});
const { data: comments } = useQuery(['comments', posts?.[0]?.id], fetchComments, {
  enabled: !!posts?.[0]?.id,
});

Three sequential requests. If each takes 300ms, you're looking at 900ms of waterfall before the user sees anything useful. This is the exact pattern that GraphQL was invented to solve — but TanStack Query gives you the tools to handle it without abandoning REST.

The Fix: Use useQueries for parallelization and flatten your data dependencies.

// ✅ Parallel fetching when IDs are known
const results = useQueries({
  queries: postIds.map((id) => ({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id),
    staleTime: 1000 * 60 * 5,
  })),
});

When true dependencies exist (you genuinely need User before Posts), consider fetching combined data at the server level, or use TanStack Query's prefetching capabilities to kick off dependent queries earlier in the component tree.

Prefetching Is Your Best Friend

// Prefetch on hover — instant feels
queryClient.prefetchQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

Attaching prefetches to hover events or route transitions means data is ready before the user needs it. This single technique can make an app feel genuinely magical.


Trap #4: You're Using Unstable Query Keys and Causing Infinite Refetch Loops

TanStack Query uses query keys as the cache identifier. If your query key changes on every render — even subtly — you've essentially disabled caching entirely and created an infinite request loop.

This trap most commonly hits developers who include objects or arrays built inline:

// ❌ This creates a new object reference on every render
const { data } = useQuery({
  queryKey: ['users', { page: currentPage, filters: activeFilters }],
  queryFn: fetchUsers,
});

Wait — that looks reasonable, right? The problem is that { page: currentPage, filters: activeFilters } is a new object reference on every render. However, TanStack Query v5 actually handles this correctly by serializing keys via deep comparison, so this specific case is safer than it was in v3.

The real danger zone is when you include functions, class instances, or non-serializable values in your query key:

// ❌ ACTUALLY BROKEN — function in query key
const { data } = useQuery({
  queryKey: ['users', filterFunction],
  queryFn: fetchUsers,
});

The Fix: Keep query keys as arrays of primitives, strings, and plain serializable objects. Never include functions, Date objects, or class instances.

// ✅ Stable, serializable query key
const { data } = useQuery({
  queryKey: ['users', { page: currentPage, status: activeStatus, search: searchTerm }],
  queryFn: () => fetchUsers({ page: currentPage, status: activeStatus, search: searchTerm }),
});

A practical rule of thumb: if you can JSON.stringify your query key and get the same result every time given the same inputs, you're good.


Trap #5: You're Not Using select to Minimize Re-renders

Here's a performance trap that's invisible until you profile your app: every time a query's data updates, every component subscribed to that query re-renders — even if the piece of data they actually care about hasn't changed.

Imagine 12 components subscribed to a large /api/dashboard query that returns hundreds of fields. One field updates. All 12 components re-render. This compounds quickly in data-heavy apps.

The Fix: Use the select option to transform and filter data at the query level.

// ❌ Re-renders whenever ANY part of the user object changes
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const userName = user?.name;

// ✅ Only re-renders when user.name specifically changes
const { data: userName } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  select: (data) => data.name,
});

TanStack Query compares the result of your select function using structural equality. If the selected slice hasn't changed, the component doesn't re-render. This is a massive win for component trees that read from shared, frequently updated queries.

Combine select with Memoization

For expensive transforms, wrap your select function in useCallback to keep it referentially stable:

const selectUserPermissions = useCallback(
  (data) => data.permissions.filter((p) => p.active),
  []
);

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

Trap #6: You're Over-Fetching Because You Haven't Implemented Pagination or Infinite Query Patterns Correctly

The final trap is a classic data architecture mistake amplified by how easy TanStack Query makes fetching feel. Because it's so simple to write useQuery, developers sometimes fetch entire datasets when they should be paginating — or they implement pagination incorrectly, losing the cache benefits entirely.

A common broken pattern:

// ❌ Fetching ALL users on every page change, losing cache
const { data } = useQuery({
  queryKey: ['users'], // ← same key regardless of page!
  queryFn: () => fetchUsers(currentPage),
});

The query key doesn't include the page number, so every page change overwrites the same cache entry. You lose instant previous-page restoration, and users feel every navigation.

The Fix: Always include pagination parameters in the query key, and use useInfiniteQuery for scroll-based patterns.

// ✅ Each page is independently cached
const { data } = useQuery({
  queryKey: ['users', { page: currentPage }],
  queryFn: () => fetchUsers(currentPage),
  placeholderData: keepPreviousData, // v5 API — shows previous page while loading next
});

// ✅ For infinite scroll — the right tool for the job
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['users', 'infinite'],
  queryFn: ({ pageParam }) => fetchUsers(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});

Note the v5 update: keepPreviousData is no longer a boolean option — it's now imported as a function from TanStack Query and passed to placeholderData. This catches many developers who migrated from v4.


Conclusion: TanStack Query Is a Performance Multiplier — When Used Correctly

Let's zoom out for a second. TanStack Query is genuinely one of the best tools in the modern frontend ecosystem. It handles server state management, background synchronization, cache invalidation, and optimistic updates with an elegance that would take months to build manually. The developers who use it well build apps that feel fast and alive in a way that's hard to achieve otherwise.

But like any powerful tool, it rewards understanding. The six traps we covered today are:

  1. Ignoring staleTime — causing constant, unnecessary refetches
  2. Recreating QueryClient on every render — destroying the cache entirely
  3. Chaining queries in waterfalls — adding hundreds of ms of avoidable latency
  4. Unstable query keys — bypassing the cache and triggering loops
  5. Skipping select — causing excessive re-renders across your component tree
  6. Incorrect pagination patterns — losing cache benefits and over-fetching

Each of these has a clear, implementable fix. And the good news is that you don't need to tackle all of them at once — even fixing traps #1 and #2 will produce a measurable improvement in most apps.

Here's your action step: Open your QueryClient configuration right now. Check your staleTime. Check how you instantiate QueryClient. Those two changes alone might be all you need to turn a sluggish app back into the snappy experience your users deserve.

Got your own TanStack Query performance story? Drop it in the comments — the best debugging wisdom always comes from the trenches.


Want to go deeper? Check out the official TanStack Query v5 migration guide and Dominik Dorfmeister's (TkDodo's) blog — the most authoritative resource on TanStack Query performance patterns outside of the official docs.

No comments

Comments

Loading comments...

Contact support