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
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.
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.
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.
Related
- Cache Layer Architecture — the parent guide covering how the cache layer sits between components and the network.
- Separating the Cache Layer From the Transport Layer — the sibling technique that makes client topology a policy decision rather than a transport one.
- Hydrating React Query in Next.js App Router — transferring a request-scoped server cache into the browser client.
- State Architecture & Cache Fundamentals — the wider section on cache-layer design and state boundaries.