Workbench

Query client

A per-request client on the server, a singleton in the browser, sensible defaults, and retry rules that give up on client errors.

From: the resort website and the company website with its CMS. A teammate set up the client in both projects; the retry and dehydration fix and the admin staleTime below are mine.

One client per request, one per tab

In the App Router, the same code runs on the server and in the browser, and they need different lifetimes for the client:

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

let browserQueryClient: QueryClient | undefined;

// React's cache() scopes this to a single server request.
const getServerQueryClient = cache(() => makeQueryClient());

export function getQueryClient() {
  if (isServer) {
    return getServerQueryClient();
  }

  if (!browserQueryClient) {
    browserQueryClient = makeQueryClient();
  }

  return browserQueryClient;
}
  • Server: a new client for every request, so one visitor's data can never leak into another visitor's page. cache() means generateMetadata and the page component in the same request share one client, and its cache.
  • Browser: one client for the whole tab, so data survives client-side navigation.

Defaults

src/lib/query/get-query-client.ts
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: shouldRetryQuery,
        refetchOnWindowFocus: process.env.NODE_ENV === 'production',
        staleTime: 5 * 60 * 1000, // 5 minutes
        gcTime: 10 * 60 * 1000, // 10 minutes
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          (query.state.status === 'pending' && query.state.fetchFailureReason === null),
      },
    },
  });
}
  • staleTime above zero. With the default of 0, a query prefetched on the server is already stale when it reaches the browser and refetches straight away. Five minutes suits public content that rarely changes.
  • No refetch on window focus in development. Switching between the editor and the browser would otherwise refetch everything.

Retry rules

A failed request is retried up to twice in production, but some failures will never succeed on a retry. The first version only gave up on 401 and 403:

Before
if (error instanceof AxiosError && [401, 403].includes(error.response?.status ?? 0)) {
  return false;
}

With that rule, a 404 for a missing room or blog post was retried twice before the page could show "not found", and a 400 or 422 was retried too. I changed it to give up on every client error:

After
function shouldRetryQuery(failureCount: number, error: unknown) {
  if (process.env.NODE_ENV === 'development') {
    return false;
  }

  if (failureCount >= 2) {
    return false;
  }

  if (error instanceof AxiosError) {
    const status = error.response?.status ?? 0;
    // Client errors (4xx) won't succeed on retry.
    if (status >= 400 && status < 500) {
      return false;
    }
  }

  return true;
}

The rule

4xx means the request itself is wrong, so asking again gives the same answer. Retry 5xx and network errors, which can be temporary.

In the same change, the dehydrate rule stopped sending a pending query to the browser if it had already failed once (fetchFailureReason === null). Otherwise the client would pick up a request that the server already knew was failing.

Shorter staleTime for admin screens

Five minutes is right for a public page and wrong for a dashboard: an editor saves a record in one tab and expects to see it in the list. In the company CMS, admin queries use a shared constant instead:

src/configs/query.constants.ts
/**
 * Default `staleTime` for admin list/detail queries. Short enough that
 * navigating list -> detail -> back to list within this window skips a
 * refetch, long enough that edits made elsewhere still show up quickly.
 */
export const DEFAULT_QUERY_STALE_TIME_MS = 30 * 1000;
ContentstaleTimeWhy
Public pages (published blogs, home content)5 minutesChanges rarely; prefetched on the server
Admin lists and details30 secondsEditors expect their changes to show up

Last updated on

On this page