Separating the Cache Layer From the Transport Layer
The most common structural mistake in a server-state codebase is that staleTime lives inside the same function as fetch. Once cache policy and request mechanics share a scope, every transport change becomes a cache change: swapping axios for fetch touches the freshness rules, adding an auth header touches the query keys, and moving one endpoint to GraphQL means rewriting the invalidation logic for that resource. This page shows the boundary that prevents that, as an applied case of the layering described in Cache Layer Architecture. If you are also deciding how many clients that layer should sit behind, read Choosing Between Single and Multiple Query Clients alongside this; and if the resources you are wrapping return nested payloads, Flattening Deeply Nested GraphQL Responses covers what the transport should hand back.
Prerequisites
- A server-state library in place — TanStack Query v5, SWR v2 or Apollo Client v3.
- Query keys already centralised, or at least a plan to centralise them.
- A clear list of the resources your application reads, independent of how many components read them.
- Agreement on what an application-level error means, as distinct from an HTTP status.
Step 1 — Write Resource Functions That Know Nothing About Caching
The transport layer’s only job is to turn typed parameters into typed data, or throw. It must not import your query client, your key factory or your cache options.
// transport/todos.ts — no cache imports anywhere in this file.
import { ApiError } from './errors';
export interface Todo { id: string; title: string; done: boolean; version: number }
export interface TodoFilters { status?: 'active' | 'done'; assignee?: string }
async function request<T>(path: string, init: RequestInit & { signal?: AbortSignal }): Promise<T> {
const response = await fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
if (!response.ok) throw await ApiError.fromResponse(response);
return response.json() as Promise<T>;
}
export const todosTransport = {
list: (filters: TodoFilters, signal?: AbortSignal) =>
request<Todo[]>(`/todos?${new URLSearchParams(filters as Record<string, string>)}`, { signal }),
detail: (id: string, signal?: AbortSignal) => request<Todo>(`/todos/${id}`, { signal }),
update: (id: string, patch: Partial<Todo>) =>
request<Todo>(`/todos/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
};
Cache Behavior Analysis: Because these functions return plain promises and accept an optional AbortSignal, they can be called by TanStack Query, by a Next.js server component, by a service worker, or by a test — none of which share a cache. The signal is an explicit parameter rather than something read from context precisely so the cache layer can own cancellation: when a query is superseded, the library aborts the controller it created and the signal it passed down does the rest. Throwing a single ApiError type rather than returning a Response means the cache layer’s retry predicate can make decisions on a stable shape instead of parsing status codes.
Step 2 — Declare Cache Policy Beside the Key
Every cache decision for a resource lives in one object: the key, the function to call, and the freshness rules. Components consume it; they do not restate it.
// cache/todos.ts — the only file that knows both keys and policy.
import { queryOptions } from '@tanstack/react-query';
import { todosTransport, type TodoFilters } from '../transport/todos';
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
detail: (id: string) => [...todoKeys.all, 'detail', id] as const,
};
export const todoQueries = {
list: (filters: TodoFilters = {}) =>
queryOptions({
queryKey: todoKeys.list(filters),
queryFn: ({ signal }) => todosTransport.list(filters, signal),
// Policy lives here, once, for every component that reads this resource.
staleTime: 30_000,
gcTime: 5 * 60_000,
}),
detail: (id: string) =>
queryOptions({
queryKey: todoKeys.detail(id),
queryFn: ({ signal }) => todosTransport.detail(id, signal),
staleTime: 60_000,
// A missing todo will not become un-missing on retry.
retry: (failureCount, error) =>
error instanceof ApiError && error.status === 404 ? false : failureCount < 2,
}),
};
Cache Behavior Analysis: queryOptions is v5’s typed options builder: it returns an object that useQuery, useSuspenseQuery, prefetchQuery and fetchQuery all accept, with the data type inferred from queryFn and carried through to every consumer. That single fact is what makes the boundary hold — a component calling useQuery(todoQueries.detail(id)) cannot accidentally supply a different staleTime, because it never names one. The retry predicate reads ApiError.status rather than inspecting a Response, which is only possible because the transport normalized the error on the way through.
Step 3 — Normalize Transport Errors Into One Domain Type
A cache layer that branches on error.response.status has a transport dependency hiding in its policy. One error class removes it.
// transport/errors.ts
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null,
readonly retryable: boolean,
) {
super(message);
this.name = 'ApiError';
}
static async fromResponse(response: Response): Promise<ApiError> {
let code: string | null = null;
let message = `${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as { code?: string; message?: string };
code = body.code ?? null;
if (body.message) message = body.message;
} catch {
// A non-JSON error body — a proxy timeout page, for instance.
}
// The transport decides what is retryable, because that is a property
// of the protocol, not of any particular screen.
return new ApiError(message, response.status, code, response.status >= 500 || response.status === 429);
}
}
Cache Behavior Analysis: With retryable computed at the transport boundary, the cache layer’s retry predicate becomes (count, error) => error instanceof ApiError && error.retryable && count < 2 — a policy statement with no protocol knowledge in it. TanStack Query stores the thrown error on query.state.error and hands the same instance to every observer, so a shared error type also means every component’s error boundary sees one shape. Attempting to read the JSON body inside a try matters because error responses from proxies and load balancers are frequently HTML, and an unhandled parse failure there would replace a useful 503 with an opaque SyntaxError.
Step 4 — Prove the Boundary by Swapping the Transport
The boundary is only real if it can be crossed. A test that substitutes the transport with no changes to cache code is the proof.
import { QueryClient } from '@tanstack/react-query';
import { todoQueries } from '../cache/todos';
import * as transport from '../transport/todos';
test('cache policy is independent of transport', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Replace the transport wholesale. No cache file is touched.
vi.spyOn(transport.todosTransport, 'list').mockResolvedValue([
{ id: 't1', title: 'Ship the boundary', done: false, version: 1 },
]);
await client.prefetchQuery(todoQueries.list({ status: 'active' }));
// The entry is addressed by the key the policy layer chose.
const entry = client.getQueryState(['todos', 'list', { status: 'active' }]);
expect(entry?.status).toBe('success');
// And it is fresh according to the policy layer's staleTime, not the mock's.
expect(client.getQueryCache().find({ queryKey: ['todos', 'list', { status: 'active' }] })?.isStale())
.toBe(false);
});
Cache Behavior Analysis: prefetchQuery populates the cache without creating an observer, so the assertion measures the cache’s own state rather than a component’s view of it. Checking isStale() immediately after the prefetch verifies that the staleTime from todoQueries.list was applied — if a component had been allowed to override it, this test would be the place that fails. Mocking at the transport module boundary rather than mocking fetch globally keeps the test honest about which seam it is exercising; a global fetch mock would still pass if cache policy had leaked into the transport file.
Edge Cases & Gotchas
Query functions that need request context. A resource whose URL depends on the current tenant tempts you to read context inside the transport. Pass it as a parameter and include it in the key instead — otherwise two tenants share one cache entry:
list: (tenantId: string, filters: TodoFilters) =>
queryOptions({
queryKey: [...todoKeys.lists(), tenantId, filters],
queryFn: ({ signal }) => todosTransport.list(tenantId, filters, signal),
}),
Transports that paginate differently. A REST endpoint returning Link headers and a GraphQL endpoint returning pageInfo should both surface as the same cursor shape to the cache layer. Do the translation in the transport, so useInfiniteQuery’s getNextPageParam is written once against your shape rather than against whichever backend you happen to be talking to.
Mutations that need optimistic access to the key factory. A mutation legitimately needs both the transport function and the keys to invalidate. Keep it in the cache layer with the queries — the mutation is policy, and its mutationFn is the only line that reaches across the seam.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Changing the API base URL changes cache behaviour | The base URL is embedded in query keys | Move it into the transport; keys should name resources, not endpoints |
Two components read one resource with different staleTime |
Options are restated at each useQuery call site |
Return queryOptions from the cache layer and pass the whole object |
| Retry predicates duplicate status-code parsing | The transport returns a raw Response or a library-specific error |
Normalize to one ApiError with a retryable flag |
| Cancellation stops working after a transport refactor | The AbortSignal was dropped when the request helper changed |
Make signal an explicit parameter and assert on it in a test |
| Multi-tenant data leaks between accounts | Tenant identity is read from context in the transport, not present in the key | Pass the tenant as a parameter and include it in the key |
Frequently Asked Questions
Does this boundary add a layer for the sake of purity?
It removes one. Without it, cache policy is scattered across every useQuery call site, so changing staleTime for a resource means finding every component that reads it and hoping you found them all. The cache layer is one small file per resource that already had to exist somewhere — the keys had to live somewhere, the freshness rules had to live somewhere. Putting them in the same place as each other, and in a different place from fetch, is not an extra abstraction; it is choosing where the abstraction that already exists should sit.
Where should retry logic live — transport or cache?
Both, split by what the decision depends on. A 503 accompanied by a Retry-After header is a protocol-level instruction and belongs to the transport, which is the only layer that knows the header exists. Deciding that a particular query should give up after two attempts, or should not retry at all on 404, is a policy statement about that resource and belongs beside the key. The clean test is whether the rule would survive swapping REST for GraphQL: protocol rules would not, policy rules would.
How do I keep the AbortSignal working across the boundary?
Make it an explicit parameter on every transport function, even the ones that do not currently use it. The signal is transport-shaped but cache-owned — the query library creates the controller and aborts it when a fetch is superseded — so it has to cross the seam deliberately rather than being pulled from a closure or a module-level variable. Adding it to the signature everywhere also means a future transport that gains cancellation support does not need its callers changed.
Related
- Cache Layer Architecture — the parent guide on how the cache layer sits between components and the network.
- Choosing Between Single and Multiple Query Clients — the sibling decision about how many caches this layer should sit in front of.
- Designing Stable Query Keys for React Query — the key-stability rules the policy layer is responsible for enforcing.
- State Architecture & Cache Fundamentals — the wider section on cache-layer design and storage models.