Skip to main content

← Blog

React Query Defaults Are Architecture

How I turned React Query cache freshness, retries, invalidation, and provider placement into platform policy, with realtime lessons from GitMyABI.

Published
February 27, 2026
Updated
Updated July 13, 2026
Author
Daniel Mark
Reading time
11 min read

Every useQuery call inherits a cache policy.

If that policy is not defined at the platform level, feature authors end up making architectural decisions inside individual hooks. One screen refetches whenever the browser regains focus. Another keeps data fresh for five minutes. A third retries an authentication failure three times before displaying an error.

Each decision may be defensible in isolation. Together, they produce an application whose data layer behaves differently from route to route.

In Atlas, I centralized the default policy in createQueryClient(). It defines how long data remains fresh, how long inactive cache entries survive, which lifecycle events trigger revalidation, and which failures deserve another attempt.

Those defaults are not universally correct. They are a starting contract. Products with fast-moving state, such as GitMyABI’s contract builds, need stronger synchronization through polling, invalidation, and WebSocket events.

The architecture is not the configuration object alone. It is the relationship between defaults, explicit overrides, and the mechanisms that keep cached data aligned with the server.

Cache policy has four responsibilities

React Query is commonly introduced as a data-fetching library, but the more useful model is a client-side cache with four responsibilities.

Freshness

staleTime determines how long cached data is considered fresh.

While a query is fresh, React Query can reuse its cached result without treating it as eligible for normal stale-query revalidation. Once the duration expires, the data becomes stale.

That distinction is important: becoming stale does not itself trigger a request. staleTime changes the status of the cached data. Something else must initiate the next fetch.

Retention

gcTime determines how long an inactive query remains in memory before garbage collection.

A query becomes inactive when no mounted component is observing it. Keeping inactive data temporarily can make back navigation feel immediate, but retaining too much for too long increases memory use and the chance of reviving outdated state.

Revalidation

Revalidation decides which events may fetch data again:

  • a component mounting;
  • the browser regaining focus;
  • the network reconnecting;
  • an explicit polling interval;
  • a manual refetch();
  • a mutation invalidating affected keys;
  • a realtime event patching or invalidating the cache.

These mechanisms are not interchangeable. Window-focus revalidation is event-driven. It is not polling. Polling is a timed process controlled through options such as refetchInterval.

Failure recovery

Retry policy decides which failed requests should run again and how long the application waits between attempts.

A temporary network failure or 503 response may recover. A 401 or validation error usually will not. Retrying both categories identically wastes requests and delays useful feedback.

Together, these four responsibilities define how the application treats server state.

The defaults Atlas chose

At the time of this article, Atlas used TanStack Query 5.62.11 and created its query client through a central factory:

1
export function createQueryClient() {
2
return new QueryClient({
3
defaultOptions: {
4
queries: {
5
staleTime: 60 * 1000,
6
gcTime: 10 * 60 * 1000,
7
refetchOnWindowFocus: false,
8
refetchOnMount: false,
9
refetchOnReconnect: false,
10
retry: (failureCount, error) => {
11
if (failureCount >= 3) return false;
12
return isRetryableError(error);
13
},
14
retryDelay: (attemptIndex) =>
15
Math.min(1000 * Math.pow(2, attemptIndex), 30000),
16
},
17
mutations: {
18
retry: false,
19
},
20
},
21
});
22
}

The client is initialized through useState(() => createQueryClient()), ensuring that client renders do not construct a new cache.

The policy contains several deliberate opinions.

Responsibility

Atlas default

Available override

Freshness

60-second staleTime

Set a different duration per query

Retention

10-minute gcTime

Retain reference data for longer or volatile data for less

Lifecycle revalidation

Disabled on mount, focus, and reconnect

Enable individual triggers per query

Query retries

Three retries for selected transient failures

Disable or replace the predicate

Mutation retries

Disabled

Enable only for operations that are safe to repeat

Synchronization

Explicit invalidation or refetching

Add polling, sockets, or domain-specific cache updates

A 60-second staleTime means the cache treats data as fresh for one minute. After that minute, the data becomes stale, but no request happens merely because the clock expired.

The disabled mount, focus, and reconnect settings make the policy especially conservative. A cached query can remain stale indefinitely unless an explicit mechanism causes it to fetch again.

That reduces lifecycle-driven request noise, but it transfers responsibility elsewhere. Mutations must invalidate the right keys. Volatile screens must opt into polling or realtime updates. Features that care about reconnecting must enable that behavior themselves.

This is why defaults are architecture. A few booleans determine which parts of the system are responsible for restoring consistency.

Retry policy needs a total budget

Atlas retries network errors and HTTP 408, 429, 502, 503, and 504 responses. Authentication, authorization, validation, and missing-resource errors fail without query-level retries.

The predicate allows three retries after the initial failure, producing at most four React Query attempts. Retry delays grow exponentially and are capped at 30 seconds. Mutations do not retry by default because repeating a write can be unsafe unless the operation is idempotent.

There is another layer to consider: the centralized HTTP client can also retry selected transient failures before returning an error to React Query.

That creates a compounded retry budget. If the HTTP client performs multiple attempts for every query attempt, React Query may repeat that entire sequence up to four times. Layered retries can improve resilience, but they can also amplify load and keep users waiting through two independent backoff strategies.

The system therefore needs one clearly documented total budget. Either one layer should own retries, or the combined number of underlying HTTP attempts and maximum delay should be calculated and capped deliberately.

“Both layers retry transient errors” is not enough of a policy.

Stale data needs an explicit way home

Because Atlas disables the usual lifecycle triggers, freshness depends on deliberate synchronization.

The demo and feature hooks demonstrate several available mechanisms:

  • mutations invalidate affected list and detail keys;
  • manual controls can call refetch();
  • individual queries can override retry and freshness options;
  • placeholder data can preserve continuity while query parameters change;
  • volatile products can introduce polling or WebSocket-driven updates.

The most important mechanism is mutation invalidation.

When a write succeeds, every cached representation affected by that write must either be updated directly or marked stale and refetched. A user update may affect the user detail, a users list, an account summary, and any derived permissions.

Centralized query-key hierarchies make that manageable. Atlas uses keys shaped around resources and views, such as:

1
["users", "list", params]
2
["users", "detail", userId]

Hierarchical keys allow a mutation to invalidate all user lists or one specific detail entry without clearing unrelated cache state.

The dangerous failure mode is not that data becomes stale after 60 seconds. The danger is that stale data remains on screen because nothing initiates another fetch.

A short staleTime cannot compensate for missing invalidation.

An abstraction is only architecture when teams use it

Atlas includes createQuery and createMutation helpers intended to standardize query keys, error normalization, option overrides, and mutation invalidation.

The idea is sound. A platform wrapper should encode the conventions the platform owns while preserving React Query’s native options for feature-specific behavior.

But the repository told a more honest story: the feature implementations still used raw useQuery and useMutation calls. The factories existed, but they had not become the dominant implementation path.

That distinction matters.

An abstraction does not become architecture because it has a good filename or sits in a shared directory. It becomes architecture when teams adopt it consistently, documentation teaches it, tests protect it, or tooling enforces it.

At this stage, Atlas had two choices:

  1. Adopt the factories across representative features and enforce the convention.
  2. Keep raw React Query hooks as the official approach and standardize only query keys, error handling, and invalidation guidance.

Leaving both patterns available without a clear recommendation creates the same ambiguity the abstraction was meant to remove.

The platform should own as little wrapper surface as possible. Recreating React Query behind a proprietary API would make upgrades harder. Standardizing keys, normalized errors, and invalidation can be enough.

Provider placement is also policy

At publication time, Atlas mounted ReactQueryProvider inside its root MainProvider. Every route inherited the query client, including server-first marketing routes that did not use client-side queries.

That placement was convenient. There was one provider, one client lifecycle, and no possibility of an interactive route accidentally rendering outside the cache boundary.

It also made the provider broader than necessary.

Marketing pages could remain server components, but the route tree still included the client provider and React Query runtime. As Atlas separated its public pages from interactive demos and application routes, a narrower provider boundary became the more appropriate direction.

In the Next.js App Router, that means placing the provider in the layouts that own interactive server state rather than automatically wrapping the entire site.

Route hierarchy showing the root layout branching into server-first marketing routes and separate demo and application layouts, each with its own React Query provider.

This is not a claim that every application must scope React Query below the root. Provider placement should follow the product.

For an authenticated application in which nearly every route consumes client-side server state, a root-level provider is perfectly reasonable. For a site combining static marketing pages with a smaller interactive application, route-scoped providers create a clearer boundary.

GitMyABI: when the default is not fresh enough

GitMyABI provides the useful contrast.

A smart-contract build can move from pending to running to completed or failed within seconds. Waiting for a user action to revalidate the cache would make the interface feel disconnected from the underlying process.

The product combines several synchronization strategies.

  1. A user triggers a build through a mutation.
  2. When the mutation succeeds, the new build is inserted into the cached project-build list with setQueryData.
  3. The relevant build query is invalidated so the client can reconcile with server state.
  4. The build list polls every 30 seconds unless polling is disabled in favor of socket coverage.
  5. A project WebSocket subscription receives build lifecycle events.
  6. A build-started event patches the cached status to running.
  7. Terminal events trigger a refetch to reconcile the list with authoritative server state.

The direct cache patch on build start addresses a real race: the event stream may know that work has begun before the HTTP API returns the updated state. Immediately refetching could replace running with an older pending response.

Completion and failure take the safer path. Instead of reconstructing the entire authoritative result from an event payload, the client refetches the server representation.

The detail view uses a more aggressive policy while a build is active. It polls status every few seconds and streams logs through a WebSocket. Once the build reaches a terminal state, those mechanisms stop.

This is the distinction between a platform default and product architecture.

Atlas supplies the cache, error, retry, and key conventions. GitMyABI decides that build state requires polling, socket events, direct cache patches, reconnect behavior, and server reconciliation.

Failure modes worth designing for

A centralized default does not remove trade-offs. It makes them visible.

Stale state can persist indefinitely. With lifecycle revalidation disabled, missing invalidation is not repaired simply because staleTime expires.

Retries can multiply across layers. HTTP-client retries and query retries need one combined request and delay budget.

Direct cache patches can race with server responses. Optimistic or socket-driven state should not be overwritten by an older API representation.

Polling and sockets can disagree. Products need a clear source of truth and a reconciliation strategy after reconnecting.

Provider boundaries can be too broad or too narrow. A root provider adds client infrastructure to routes that may not use it. A narrowly scoped provider can be forgotten when new interactive route groups are introduced.

Unused abstractions create false confidence. Factories and helper APIs should either become the supported path or be removed.

These are not arguments against centralized defaults. They are the reason to document them as architecture rather than treating them as library configuration.

What I would improve next

First, I would define a freshness matrix for common resource categories. Reference data, authenticated profile state, paginated lists, and active jobs should not share one implicit policy.

Second, I would establish a single retry owner or document the combined retry budget across the HTTP client and React Query.

Third, I would add regression tests for the production query-client defaults, retry predicate, and representative mutation invalidation paths.

Fourth, I would move the provider closer to the interactive route trees while documenting which layouts are expected to own client-side server state.

Finally, I would make a clear decision about the query and mutation factories: adopt and enforce them, or keep raw hooks and standardize the smaller primitives that already have real usage.

Closing

React Query defaults are cache policy written once.

staleTime defines when data becomes eligible for revalidation, not when it automatically refreshes. Retry predicates define both resilience and request amplification. Query keys determine whether mutations can invalidate precisely. Provider placement determines where the client-side data layer exists at all.

Realtime products will always need stronger domain-specific behavior. A build system may combine optimistic updates, polling, WebSockets, and authoritative refetching. That does not invalidate the platform default. It demonstrates why the default needs explicit escape hatches.

The platform’s job is not to impose one freshness policy on every screen. It is to make the common path predictable, make deviations intentional, and ensure every cached value has a deliberate way back to the server.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.