This page maps the routing decision from Custom Hooks for Async Error Catching onto TanStack Query, where the cache adds a step most teams miss.

The exact problem #

An orders table uses useQuery. The endpoint returns 500. The component renders error inline — fine. Then a second query on the same page also fails, and a third, and the page fills with three different error cards in three different visual styles, while a fourth component that genuinely cannot render without data shows an empty table with no explanation at all.

Meanwhile the boundary that is on the page never fires, because by default TanStack Query returns errors as state rather than throwing them. And when a team turns on throwOnError globally, the retry button in their fallback stops working: resetting the boundary re-renders a component that reads the same cached error and rethrows instantly.

Zero-to-working configuration #

// queryClient.ts
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  queryCache: new QueryCache({
    // One reporting path for every query failure, however it is displayed.
    onError: (error, query) => {
      reportCrash({
        scope: `query:${String(query.queryKey[0])}`,
        message: error instanceof Error ? error.message : String(error),
        queryKey: JSON.stringify(query.queryKey).slice(0, 200),
        status: (error as { status?: number }).status,
      });
    },
  }),
  mutationCache: new MutationCache({
    onError: (error, _vars, _ctx, mutation) => {
      reportCrash({
        scope: `mutation:${String(mutation.options.mutationKey?.[0] ?? 'unknown')}`,
        message: error instanceof Error ? error.message : String(error),
      });
    },
  }),
  defaultOptions: {
    queries: {
      // Transient failures should never reach the user; permanent ones fast.
      retry: (failureCount, error) => {
        const status = (error as { status?: number }).status ?? 0;
        if (status >= 400 && status < 500) return false;   // never retry client errors
        return failureCount < 2;
      },
      retryDelay: (attempt) => Math.min(400 * 2 ** attempt, 2000),

      // Boundary for anything the component cannot render around.
      throwOnError: (error) => {
        const status = (error as { status?: number }).status ?? 0;
        return status === 0 || status >= 500;              // network + server errors
      },
      staleTime: 30_000,
    },
    mutations: {
      // Mutations stay inline: a failed save belongs next to the form.
      throwOnError: false,
      retry: false,
    },
  },
});
Where each class of failure goes A query failure node branches three ways. Client errors in the four hundred range go straight to inline handling with no retry. Server and network errors retry twice with backoff and then throw to the nearest error boundary. A separate mutation failure node always routes to inline handling beside the form. query failure 4xx 5xx / network inline, no retry "not found" / "no access" retry ×2, backoff 400 ms → 800 ms, capped throw → nearest boundary fallback with a working retry mutation failure always inline, beside the form every branch is reported once, from the cache-level onError handler

Making the boundary’s retry actually refetch #

This is the piece that is specific to a caching data layer. The boundary reset must also reset the query, or the component rethrows the cached error immediately.

// QueryBoundary.tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';

export function QueryBoundary({ scope, children }: { scope: string; children: React.ReactNode }) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <RetryableBoundary
          scope={scope}
          // Clear the failed query state BEFORE the subtree remounts.
          onReset={reset}
          fallback={<SectionError scope={scope} />}
        >
          {children}
        </RetryableBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

reset() clears the error state of every query that failed inside this boundary’s subtree, so the remount issues a genuine refetch. Without it the retry is the dead button described in Adding a Retry Button That Recovers Without a Full Reload.

Step-by-step #

  1. Make throwOnError a predicate. A boolean forces one policy on every query; a predicate lets 404 render “no results” inline while 500 goes to a boundary.
  2. Turn off retry for 4xx. Retrying a 403 three times wastes two seconds of the user’s life to produce the same 403.
  3. Wrap with QueryErrorResetBoundary wherever you throw. The pairing is mandatory, not optional: throwOnError without reset produces unrecoverable fallbacks.
  4. Keep mutations inline. A failed save must not replace the form that holds the user’s unsaved input — the loss described in Form State Recovery & Multi-Step Wizards.
  5. Report from the caches. QueryCache.onError and MutationCache.onError fire once per failure regardless of display, which keeps reporting complete without scattering handlers through components.
  6. Include the query key in the report. It is the closest thing to a component stack for a data failure, and it makes the fingerprinting in Deduplicating Error Noise with Fingerprinting and Sampling meaningful.
Query situation throwOnError Retry Display
Detail page’s primary record, 500 true 2 Boundary fallback with retry
Detail page’s primary record, 404 false 0 “Not found” state with a link back
Sidebar recommendations, any failure false 1 Inline empty state, silent
Auth-required query, 401 false 0 Escalate to session handling
Paginated table, page 3 fails false 2 Keep page 2 visible, inline banner
Background refetch of visible data false 2 Silent; keep the stale data on screen

The last row matters for perceived reliability: a background refetch that fails should never destroy the data already on screen. TanStack Query’s default behaviour is correct here — the failure surfaces as isError while data remains — and turning on throwOnError globally breaks it by throwing away good data because a refresh failed.

Failed background refetch under two configurations Two panels. The left panel, labelled global throwOnError, shows a table replaced entirely by an error fallback after a failed background refresh. The right panel, labelled scoped throwOnError, shows the same table still populated with a small banner reading could not refresh, showing data from two minutes ago. global throwOnError scoped throwOnError fallback replaces the table good data thrown away because a refresh failed could not refresh — showing data from 2 min ago stale data still beats no data a refresh failure becomes a page failure Time to a visible error, by status class Two timelines. The 4xx line reaches its inline error almost immediately. The 5xx line shows two backoff retries before the boundary fallback appears about one and a quarter seconds later. 4xx inline error, no retries 5xx 400 ms 800 ms boundary fallback retrying a 403 three times spends the user's time to reproduce the same 403

Verification #

  1. Force a 500 on a primary query. After two retries the boundary fallback appears; the retry button then issues a fresh request — check the network panel for the new call.
  2. Force a 404. No boundary, no retries, an inline “not found” state.
  3. Fail a background refetch. The visible rows must remain, with a non-destructive banner.
  4. Fail a mutation. The form stays mounted with its values, an inline message appears, and one mutation: scoped report is sent.
  5. Count reports. One per failure. If a failure that hits a boundary is reported twice, the boundary is reporting on top of the cache handler — pick one.

One more configuration detail is worth stating explicitly: the predicate you pass to throwOnError runs for refetches as well as first loads, so a background refresh of a healthy query can throw and replace working content. Guarding the predicate on whether data already exists — throw only when there is nothing to show — keeps stale data on screen while still routing genuine first-load failures to the boundary.

Frequently asked questions #

Why does my boundary retry immediately fail again with React Query?

Because the query is still in an error state in the cache, so the remount rethrows it. Wrap in QueryErrorResetBoundary and call reset in the retry handler.

Should every query use throwOnError?

No. Use it where the component cannot render meaningfully without the data. A predicate — throw on 5xx, handle 4xx inline — is the usual default.

How do I stop retries from delaying the error for ten seconds?

Make retry conditional on error type: never retry 4xx, retry network and 5xx twice with a short capped backoff.