Web Development
ReactReact QueryTanStack QueryNext.jsApp RouterCachingPerformanceFrontend Architecture

React Query at Scale: Cache Invalidation, Pagination, and Mutation Patterns for Real Apps

AO
Adrijan Omićević
·16 min read

# What This Guide Covers#

React Query is easy to start with and deceptively hard to scale. As the app grows, teams usually hit the same issues: broad invalidations that trigger over-fetching, pagination caches that drift, optimistic updates that break with concurrency, and stale UI that quietly undermines user trust.

This guide focuses on React Query cache invalidation best practices and scalable patterns for query key design, targeted invalidation, pagination and infinite queries, mutation patterns with optimistic updates, and background refetching in real production apps. Examples target Next.js App Router with client components.

If you are deciding between data fetching libraries in App Router, read React Query vs SWR in Next.js App Router. If your question is how React Query fits with platform caching, see Next.js caching strategies: SSR, ISR, SWR.

# Prerequisites#

RequirementVersionNotes
React18+Required for App Router and concurrency behavior
Next.js14+App Router examples assume modern routing conventions
TanStack Query5+React Query v5 patterns and APIs
Basic REST or GraphQL APIExamples use REST-like endpoints
TypeScriptRecommendedStrongly helps with query key safety

# The Scaling Problem: Why Cache Invalidation Gets Expensive#

React Query saves network calls by caching server state. At scale, the cost shifts from “number of requests” to “predictability of updates”.

Three real-world failure modes show up repeatedly:

  1. 1
    Over-fetching: a single mutation invalidates broad prefixes, refetching many screens and burning API quota.
  2. 2
    Stale UI: a user changes something, the mutation succeeds, but some lists and detail views keep showing old data until a random refetch happens.
  3. 3
    Pagination drift: paginated lists and “counts” disagree, because only one cache entry was updated.

A useful mental model: server state in React Query is a set of materialized views. Your job is to keep those views consistent with minimum refetch.

🎯 Key Takeaway: At scale, the goal is not “never stale” or “never refetch”. The goal is predictable freshness with controlled refetch scope.

# Query Key Design That Scales#

Query keys determine cache identity. The key design is what makes targeted invalidation possible later. If you get keys wrong, you will compensate with broad invalidation, and broad invalidation is where over-fetching starts.

Rules for Production Query Keys#

Use these rules consistently across the codebase:

  1. 1
    Hierarchical arrays, not strings.
  2. 2
    Stable ordering of object params, or avoid objects in keys by using explicit tuples.
  3. 3
    Scope by tenant and auth when needed, so you do not cross-contaminate caches.
  4. 4
    Separate “list” and “detail” keys so you can update each precisely.
  5. 5
    Include filters and sorting in list keys so caches do not collide.

A Practical Key Factory Pattern#

Create a single source of truth for keys per domain. It prevents “close enough” keys that break invalidation.

TypeScript
// queryKeys.ts
export const projectsKeys = {
  all: ['projects'] as const,
  lists: () => [...projectsKeys.all, 'list'] as const,
  list: (params: { q?: string; status?: 'active' | 'archived'; page: number; pageSize: number }) =>
    [...projectsKeys.lists(), params] as const,
  details: () => [...projectsKeys.all, 'detail'] as const,
  detail: (id: string) => [...projectsKeys.details(), id] as const,
};

This design lets you invalidate:

  • a single project detail: projectsKeys.detail(id)
  • all project lists regardless of filters: projectsKeys.lists()
  • everything projects-related: projectsKeys.all

⚠️ Warning: Avoid putting non-serializable values in query keys, such as functions, classes, Dates, or mutable objects. It can cause cache misses and duplicate requests that look like “React Query is refetching randomly”.

Multi-Tenant and User-Specific Keys#

If data differs by organization or user, make it explicit. The most common production bug is showing another org’s cached data after an org switch.

ScenarioKey prefix exampleWhy it matters
Single-tenant public data['projects']Simple and safe
Multi-tenant org data['org', orgId, 'projects']Prevents cross-org cache mixing
User-specific['me', userId, 'notifications']Avoids stale data after login switch
Role-based views['org', orgId, 'admin', 'auditLogs']Avoids accidental cache reuse

# React Query Cache Invalidation Best Practices#

Invalidation is the lever that keeps caches coherent. The trap is invalidating too broadly to “make bugs go away”, which increases network traffic and makes the app feel unpredictable.

Prefer Targeted Invalidation Over Global Invalidation#

A mutation should invalidate only what it can affect. In practice, that means invalidating:

  • the mutated entity detail cache
  • list caches where that entity appears
  • derived aggregates that depend on it, such as counts and dashboards
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
function useUpdateProject() {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (input: { id: string; name: string }) => {
      const res = await fetch(`/api/projects/${input.id}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: input.name }),
      });
      if (!res.ok) throw new Error('Failed to update project');
      return (await res.json()) as { id: string; name: string };
    },
    onSuccess: (updated) => {
      qc.setQueryData(projectsKeys.detail(updated.id), updated);
      qc.invalidateQueries({ queryKey: projectsKeys.lists() });
    },
  });
}

This pattern is effective because:

  • the detail page becomes consistent immediately
  • lists refetch to match server ordering, filters, and computed fields

When Invalidation Is Too Expensive#

Some lists are too expensive to refetch on every mutation, such as:

  • heavy search endpoints
  • multi-join analytics
  • slow third-party APIs

In those cases, update caches directly for the hot path and refetch less often.

Approach:

  • patch the most visible caches using setQueryData
  • schedule a background reconcile with invalidateQueries for a narrow subset
  • use longer staleTime for heavy queries to avoid repeated refetch

💡 Tip: If you have rate limits, track “refetch per mutation” as a KPI. In many apps, simply narrowing invalidation from ['projects'] to projectsKeys.lists() cuts background refetch traffic by 30 to 70 percent within a week, because you stop refreshing unrelated “detail” queries and aggregates.

Use Partial Key Matching Intentionally#

React Query lets you invalidate by prefix. This is powerful and dangerous.

Invalidation callScopeTypical use
invalidateQueries({ queryKey: projectsKeys.detail(id) })One entity detailAfter updating that entity
invalidateQueries({ queryKey: projectsKeys.lists() })All list variantsAfter create, delete, reorder
invalidateQueries({ queryKey: projectsKeys.all })Everything in domainRare, usually during logout or major sync

If your team often reaches for projectsKeys.all, that is a smell: you are missing a better key structure or a cache update step.

# Pagination Patterns That Stay Consistent#

Pagination introduces multiple caches for the “same” dataset. You need to decide what consistency means: per page, across pages, or per filter set.

Numbered Pagination with keepPreviousData#

For tables and admin views, numbered pagination is predictable and supports deep-linking. Keep the previous page visible while the next page loads, so the UI does not flicker.

TypeScript
import { useQuery } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function useProjectsPage(params: {
  q?: string;
  status?: 'active' | 'archived';
  page: number;
  pageSize: number;
}) {
  return useQuery({
    queryKey: projectsKeys.list(params),
    queryFn: async () => {
      const qs = new URLSearchParams({
        q: params.q ?? '',
        status: params.status ?? '',
        page: String(params.page),
        pageSize: String(params.pageSize),
      });
      const res = await fetch(`/api/projects?${qs.toString()}`);
      if (!res.ok) throw new Error('Failed to load projects');
      return (await res.json()) as { items: Array<{ id: string; name: string }>; total: number };
    },
    placeholderData: (prev) => prev,
    staleTime: 30_000,
  });
}

Why staleTime matters: without it, you can trigger refetch loops when users switch tabs or when components remount, which looks like “slow pagination”.

Keeping “total count” and “items” in sync#

If your API returns total, it becomes a derived cache value that can drift after create or delete. Choose one:

  • Refetch lists on create or delete, which is simplest.
  • Or update totals in cache, which is faster but error-prone across filters.

For most apps, the reliable approach is: after create or delete, invalidate the list prefix and let the server recompute totals.

# Infinite Queries for Feeds and Timelines#

Infinite scrolling is best for feeds where users keep consuming content. The key is to make the query function and getNextPageParam deterministic, and avoid invalidating the entire feed on every small mutation.

Infinite Query Setup#

TypeScript
import { useInfiniteQuery } from '@tanstack/react-query';
 
export function useActivityFeed(params: { projectId: string }) {
  return useInfiniteQuery({
    queryKey: ['projects', params.projectId, 'activity', 'infinite'],
    queryFn: async ({ pageParam }) => {
      const cursor = pageParam ? String(pageParam) : '';
      const res = await fetch(`/api/projects/${params.projectId}/activity?cursor=${cursor}`);
      if (!res.ok) throw new Error('Failed to load activity');
      return (await res.json()) as {
        items: Array<{ id: string; message: string; createdAt: string }>;
        nextCursor: string | null;
      };
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 15_000,
  });
}

Mutation Strategy for Infinite Lists#

Avoid invalidating the whole infinite query if you only add one item. For example, when posting a comment or activity item:

  • optimistically prepend to page 1
  • then invalidate only if the server can reorder items or apply moderation rules

This keeps the feed responsive and prevents expensive “reload the world” behavior.

ℹ️ Note: Infinite queries are often paired with cursor pagination. If your API uses offset pagination for infinite scroll, deletions and insertions will shift offsets and cause duplicates or gaps. Cursor-based pagination avoids that class of bugs.

# Mutation Patterns for Real Apps#

Mutations are where UX and cache correctness collide. A scalable pattern makes the user see immediate feedback while guaranteeing eventual consistency.

Pattern 1: Simple Mutations with setQueryData plus invalidate#

Use when:

  • the mutation updates a single entity
  • you have a detail view that must reflect changes instantly
  • lists can refetch in the background

We already used this in useUpdateProject. It is the default for most CRUD.

Pattern 2: Optimistic Updates with Rollback#

Use optimistic updates when waiting for the server would visibly degrade UX, such as toggles, likes, starring, or changing a status in a table.

TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function useToggleArchived(projectId: string) {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (archived: boolean) => {
      const res = await fetch(`/api/projects/${projectId}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ archived }),
      });
      if (!res.ok) throw new Error('Failed to update');
      return (await res.json()) as { id: string; archived: boolean; name: string };
    },
    onMutate: async (archived) => {
      await qc.cancelQueries({ queryKey: projectsKeys.detail(projectId) });
 
      const prev = qc.getQueryData<{ id: string; archived: boolean; name: string }>(
        projectsKeys.detail(projectId)
      );
 
      qc.setQueryData(projectsKeys.detail(projectId), (current: any) =>
        current ? { ...current, archived } : current
      );
 
      return { prev };
    },
    onError: (_err, _archived, ctx) => {
      if (ctx?.prev) qc.setQueryData(projectsKeys.detail(projectId), ctx.prev);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
      qc.invalidateQueries({ queryKey: projectsKeys.lists() });
    },
  });
}

Key points:

  • cancel in-flight queries so your optimistic patch does not get overwritten
  • snapshot previous state for rollback
  • always reconcile after settle

Pattern 3: Updating Multiple Caches Without Refetching Everything#

If a project name changes, it affects:

  • detail query
  • every list page that includes that project
  • search results caches

In this case, it can be worth patching list caches to avoid a refetch storm.

Strategy:

  • update detail cache
  • patch list caches currently in memory by iterating over matching queries
TypeScript
import { useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function patchProjectNameEverywhere(qc: ReturnType<typeof useQueryClient>, input: { id: string; name: string }) {
  qc.setQueryData(projectsKeys.detail(input.id), (p: any) => (p ? { ...p, name: input.name } : p));
 
  const listQueries = qc.getQueriesData<{ items: Array<{ id: string; name: string }>; total: number }>({
    queryKey: projectsKeys.lists(),
  });
 
  for (const [key, data] of listQueries) {
    if (!data) continue;
    qc.setQueryData(key, {
      ...data,
      items: data.items.map((it) => (it.id === input.id ? { ...it, name: input.name } : it)),
    });
  }
}

This reduces network traffic and makes the UI consistent across open tabs. It is especially useful in apps with high-frequency edits.

# Background Refetching Without Annoying Users#

Background refetching keeps data fresh, but the defaults can be noisy in large apps. The most common symptom is “my app spams requests when I tab back”.

SettingTypical valueWhy
staleTime15 to 60 secondsReduces refetch thrash on remount
gcTime10 to 30 minutesKeeps recently used data in memory
refetchOnWindowFocusfalse for non-critical queriesPrevents focus storms
refetchOnReconnecttrueGood UX after network drops
retry1 to 2Avoids retry storms during outages

Configure these in your QueryClient.

TypeScript
import { QueryClient } from '@tanstack/react-query';
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 15 * 60_000,
      refetchOnWindowFocus: false,
      refetchOnReconnect: true,
      retry: 1,
    },
  },
});

If you need different behavior per query, override locally instead of keeping everything at the global default.

Avoiding Stale UI Without Over-Fetching#

The cleanest way to avoid stale UI is not setting staleTime to zero. Instead:

  • For user-facing critical detail views, keep staleTime modest and refetch on navigation.
  • After mutations, reconcile with targeted invalidation.
  • Use optimistic updates for immediate feedback.
  • For background dashboards, set longer staleTime and provide a manual refresh button.

This results in fewer requests and better perceived performance.

# Next.js App Router: Where React Query Fits#

App Router introduces server components, route-level caching, and fetch caching semantics. React Query is still valuable, especially for interactive, authenticated, client-side server state.

A Practical Architecture for App Router#

Use this split:

  • Server components handle static or cacheable public data, SEO pages, and initial skeleton.
  • Client components use React Query for authenticated data, interactive lists, and mutations.

If you are also using Next.js fetch caching, be intentional about which layer owns freshness. A common pattern is:

  • server fetch for initial render
  • hydrate React Query with initialData for the first screen
  • let React Query own subsequent refetch and mutations

Client Component Example with App Router#

TypeScript
'use client';
 
import { useQuery } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function ProjectDetailsClient({ id }: { id: string }) {
  const q = useQuery({
    queryKey: projectsKeys.detail(id),
    queryFn: async () => {
      const res = await fetch(`/api/projects/${id}`);
      if (!res.ok) throw new Error('Failed to load project');
      return (await res.json()) as { id: string; name: string; archived: boolean };
    },
    staleTime: 20_000,
  });
 
  if (q.isLoading) return 'Loading...';
  if (q.isError) return 'Failed to load';
  return q.data.name;
}

For broader caching context, connect this with Next.js caching strategies: SSR, ISR, SWR. The important point is avoiding two caching systems fighting each other with conflicting freshness rules.

# Observability: Measure Cache Effectiveness, Not Just API Latency#

At scale, you need feedback loops. Cache invalidation issues show up as:

  • unexpected spikes in API calls after releases
  • user reports of “it saved but I still see the old value”
  • UI flicker or loading states during small interactions

Track at least these metrics:

  • query request rate per route
  • mutation count and mutation error rate
  • average time to fresh data after mutation
  • refetch-on-focus events per session

Then correlate with logs and traces. This is where observability pays off, especially for distributed backends and third-party APIs. Practical setup guidance is in Web app observability guide: logging, metrics, tracing.

💡 Tip: Add a lightweight client-side counter for invalidateQueries calls per user session. If it jumps after a feature release, you likely introduced broad invalidations or unstable query keys.

# Common Pitfalls and How to Avoid Them#

Pitfall 1: Using unstable objects in query keys#

If you create a new params object each render and it is not stable, you can create multiple cache entries.

Fix:

  • use a key factory
  • keep params minimal
  • ensure stable ordering of properties when using objects

Pitfall 2: Invalidating too broadly after every mutation#

If you invalidate ['projects'] for every project update, you will refetch detail views, lists, analytics, and anything else under that prefix.

Fix:

  • invalidate detail(id) plus lists()
  • update caches directly for visible screens

Pitfall 3: Infinite query invalidation on every small change#

Invalidating the entire feed after a small action destroys the user’s perceived performance.

Fix:

  • optimistic prepend or patch
  • reconcile with targeted refetch only when necessary

Pitfall 4: Relying on refetch-on-focus to “eventually fix it”#

This creates inconsistent behavior across browsers and user habits. Some users never blur the tab.

Fix:

  • reconcile after mutations
  • choose explicit background refetch intervals if needed

# Key Takeaways#

  • Design hierarchical, stable query keys with domain key factories so invalidation can be narrow and predictable.
  • Use targeted invalidation as the default: invalidate entity detail plus relevant list prefixes, not the entire domain.
  • Combine setQueryData with invalidateQueries for real apps: instant UI consistency now, server reconciliation shortly after.
  • For pagination, prefer keepPreviousData and invalidate list prefixes on create or delete to keep totals consistent.
  • Use infinite queries with cursor pagination and avoid invalidating the full feed for small mutations by patching page 1.
  • Reduce over-fetching with sensible defaults for staleTime and focus refetching, then verify impact via observability.

# Conclusion#

React Query scales well when you treat cache invalidation as an architecture decision, not an afterthought. Stable query keys, narrow invalidation, and disciplined mutation patterns give you predictable UI consistency without turning every user action into a refetch storm.

If you want help applying these React Query cache invalidation best practices in a Next.js App Router codebase, Samioda can audit your query keys and invalidation graph, reduce over-fetching, and implement optimistic updates safely. Reach out via samioda.com to discuss your app’s data layer.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.