Choosing Between Single and Multiple Query Clients

A QueryClient is a cache plus a set of defaults plus an invalidation namespace. Creating a second one is therefore not a small decision: you get isolation, and you lose the ability to invalidate across the boundary without building a bus. This page works through when the split pays for itself, as an applied case of the layering in Cache Layer Architecture. It assumes you have already pushed cache policy out of your request code, as described in Separating the Cache Layer From the Transport Layer; if you are working through the server-rendering case in particular, Hydrating React Query in Next.js App Router covers the transfer of a request-scoped cache to the browser.

Diagnostic Checklist

Three client topologies Left: one browser client shared by all features, with tenant identity carried in the query key. Middle: two clients, one per micro-frontend, connected by an explicit invalidation bus. Right: on the server, one client created per incoming request and discarded after the response, so no state is shared between users. One cache, several caches, or one cache per request Single client QueryClient one set of defaults feature A feature B isolation via key prefix invalidation reaches everything entities stored once the default choice Client per surface client A client B invalidation bus you write hard isolation duplicate entities in memory cross-cache invalidation is manual micro-frontends only Client per request created in the handler discarded after the response dehydrate → send to browser no cross-user leakage no reuse between requests module scope is a security bug mandatory on the server
The right-hand case is not a preference. A module-scope client on the server shares one cache across concurrent users.

Step 1 — Start From One Client and Name the Isolation You Need

Write down the property you want before choosing a topology. Most stated reasons for a second client are actually reasons for a key namespace.

// app/query-client.ts — browser singleton, created once per tab.
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: (count, error) => (error instanceof ApiError && !error.retryable ? false : count < 2),
      refetchOnWindowFocus: true,
    },
    mutations: { retry: false },
  },
});

Cache Behavior Analysis: A single client means one QueryCache, so two features requesting the same key share one entry, one in-flight request and one gcTime timer — the deduplication that makes a shared cache worth having. It also means one set of defaults; a feature that needs different freshness overrides staleTime per query rather than getting its own client. Because defaultOptions are read at query creation, changing them at runtime does not retroactively affect existing entries, which is one reason “different defaults” is a weak argument for splitting.


Step 2 — Prefer a Key Namespace Over a Second Client

Tenant, workspace and environment isolation are all achievable inside one cache by making the discriminator part of the key.

export const workspaceKeys = {
  scope: (workspaceId: string) => ['ws', workspaceId] as const,
  boards: (workspaceId: string) => [...workspaceKeys.scope(workspaceId), 'boards'] as const,
  board: (workspaceId: string, boardId: string) =>
    [...workspaceKeys.scope(workspaceId), 'boards', boardId] as const,
};

// Leaving a workspace removes exactly that workspace's entries and nothing else.
export function leaveWorkspace(client: QueryClient, workspaceId: string) {
  client.removeQueries({ queryKey: workspaceKeys.scope(workspaceId) });
}

Cache Behavior Analysis: Because removeQueries matches on key prefix, scoping every key under ['ws', id] gives you the same “throw away everything for this tenant” operation that a second client would, without giving up shared deduplication for resources that are genuinely global — the current user, feature flags, reference data. The entries are also visible in one DevTools panel and one getAll() listing, which matters more for debugging than it sounds. The one property this does not give you is protection against a coding mistake: a key that forgets the prefix silently escapes the namespace, whereas a separate client makes that impossible.

What each topology actually buys Matrix comparing a single client, one client per surface, and one client per request across isolation strength, invalidation reach, memory behaviour and where each is appropriate. What each topology actually buys Single client Client per surface Client per request Isolation strength Convention — a key prefix Structural — separate caches Structural — separate processes of work Invalidation reach Everything, via prefix matching Only within one client; a bus is required Not applicable — lives for one render Shared entity storage Yes — one entry per key No — each client caches its own copy Not applicable Where it belongs Almost every browser application Independently deployed micro-frontends Every server render, without exception
Splitting buys isolation and costs invalidation. On the server the choice is made for you.

Step 3 — Create a Request-Scoped Client on the Server

On the server, a module-level client is shared between concurrent requests in the same process. This is a data-leak bug, not a performance choice.

// app/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query';

function makeClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // Server-prefetched data must not be considered instantly stale, or the
        // browser refetches everything the moment it hydrates.
        staleTime: 60_000,
      },
    },
  });
}

let browserClient: QueryClient | undefined;

export function getQueryClient() {
  // A fresh client per server render; a single client for the browser tab.
  if (isServer) return makeClient();
  browserClient ??= makeClient();
  return browserClient;
}

Cache Behavior Analysis: isServer is exported by the library precisely for this branch, and getting it wrong is the single most common cause of one user seeing another’s prefetched data in a Next.js application. The non-zero staleTime matters just as much: with the default of 0, every entry transferred from the server is stale on arrival, so the browser refetches the whole page’s data during hydration and the server render buys nothing. Because the server client is discarded after the response, its gcTime never runs and none of its entries are ever garbage collected — which is fine, since the whole object becomes unreachable.


Step 4 — Give Micro-Frontends Separate Clients and a Shared Bus

Independently deployed surfaces are the case where a second client genuinely earns its cost — provided you accept that you now own cross-cache invalidation.

type InvalidationEvent = { prefix: unknown[]; source: string };

export function createInvalidationBus() {
  const clients = new Map<string, QueryClient>();
  const target = new EventTarget();

  return {
    register(name: string, client: QueryClient) {
      clients.set(name, client);
      target.addEventListener('invalidate', (event) => {
        const detail = (event as CustomEvent<InvalidationEvent>).detail;
        if (detail.source === name) return; // do not echo to the originator
        // Mark stale without fetching: a surface the user is not looking at
        // should not spend a request until it is mounted and visible.
        client.invalidateQueries({ queryKey: detail.prefix, refetchType: 'none' });
      });
    },
    publish(source: string, prefix: readonly unknown[]) {
      clients.get(source)?.invalidateQueries({ queryKey: prefix });
      target.dispatchEvent(
        new CustomEvent<InvalidationEvent>('invalidate', { detail: { prefix: [...prefix], source } }),
      );
    },
  };
}

Cache Behavior Analysis: The bus reproduces, by hand, what a single QueryCache gives you for free — which is the honest accounting of what splitting costs. Passing refetchType: 'none' to the peer clients is what keeps the split from multiplying network traffic: without it, one mutation in surface A triggers an immediate refetch in every other surface that happens to hold a matching key, even ones scrolled off screen. Because each client has its own entry for the same resource, the entity is stored once per client, so a heavy shared resource is duplicated in memory as many times as you have surfaces.

Heap held by cached entities as surfaces are split Bar chart showing cached entity memory. One client holds 1.4 megabytes. Two clients hold 2.8. Three hold 4.2. One client with key namespacing holds 1.4. Heap held by cached entities as surfaces are split Same workload, one shared reference dataset of 1.4 MB 1 client 1.4 MB 2 clients 2.8 MB 3 clients 4.2 MB 1 client + key namespace 1.4 MB
Splitting duplicates every shared resource. Namespacing inside one cache gives the same logical separation with none of the duplication.

Edge Cases & Gotchas

Testing with a shared client. A module-level client leaks state between test cases: a query cached in one test satisfies the next, and a failure looks like a flake. Construct a client per test and set retry: false, so a rejected queryFn fails immediately rather than after three backoff delays.

Suspense boundaries and multiple clients. Two QueryClientProviders nested in one tree means the inner one wins for everything below it — including components you did not intend to move. Keep providers at surface roots, never inside a route.

Persisted caches, multiplied. Each client that persists needs its own storage key and its own buster. Sharing a storage key between two clients means whichever writes last wins, silently discarding the other’s snapshot; see Busting a Persisted Cache on Deploy.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
A server-rendered page shows another user’s data A module-scope QueryClient is shared across concurrent server requests Branch on isServer and construct per request
The browser refetches everything immediately after hydration Server-prefetched entries are stale on arrival because staleTime is 0 Set a non-zero staleTime on the server client
Invalidation in one surface has no effect in another invalidateQueries only reaches the cache it was called on Build an explicit bus, or merge the surfaces onto one client
Memory doubles after introducing a second client Shared entities are now cached once per client Namespace keys in one cache instead of splitting
Tests pass alone and fail together A shared client carries cached data between cases Create a client per test with retry: false

Frequently Asked Questions

Is a module-level QueryClient safe in Next.js?

In the browser, yes — a module-level singleton is exactly right, because a tab is a single user’s session and you want one shared cache for its lifetime. On the server it is not safe under any circumstances. A module-scope client is created once per Node process and shared by every concurrent request that process handles, so data prefetched for one user is readable by another user’s render. The isServer branch in Step 3 is the standard fix, and it is worth a lint rule: this is a bug that never reproduces locally, because a single developer never generates concurrent requests.

Should each tenant get its own QueryClient?

Usually not. Putting the tenant id into the key prefix gives you the same isolation with one cache, one set of defaults, one DevTools panel and one invalidation surface — and it lets genuinely global resources such as the current user or feature flags stay shared instead of being fetched once per tenant. The case for a real split is different auth lifetimes: if a user can be signed into one workspace and signed out of another, and clearing one must provably not touch the other, structural isolation is worth the duplicated memory.

What breaks when two clients need to invalidate each other?

Nothing breaks automatically, which is precisely the problem — invalidateQueries silently reaches only the cache it was called on, so cross-client invalidation fails by doing nothing rather than by throwing. You have to build the bus yourself, keep every surface registered with it, and decide the refetch policy for peers. That plumbing, plus the debugging cost of having several caches to inspect when something looks stale, is the real price of splitting, and it is usually larger than the isolation is worth outside a micro-frontend architecture.