In TanStack Query v5, why does a query whose endpoint returns a 500 stay in its loading state for several seconds before an error appears?
answer
- the query tries more than once
- a default count on the client
- delay doubles each time
- failureCount and failureReason
basics
~20 sOn the client a failed query is retried 3 times by default, waiting about 1, 2 and 4 seconds between attempts. The error surfaces only after the fourth attempt fails; until then status stays pending and failureCount climbs.
solid answer
~40 sQueries retry by default: `retry` defaults to `3` in the browser and `0` on the server, and `retryDelay` defaults to exponential backoff, `Math.min(1000 * 2 ** failureCount, 30000)`. So a failing endpoint is called four times, with waits of roughly 1s, 2s and 4s, before `status` becomes `'error'` - about seven seconds plus request time. Meanwhile the query stays `pending`, and `failureCount` and `failureReason` report the attempts so far. You tune it per query or in `defaultOptions`: `retry: false`, a number, or a function `(failureCount, error) => boolean` that, for example, skips retries on 4xx responses; `retryDelay` takes a number or a function. Test suites usually set `retry: false` on their `QueryClient` so error cases do not time out.
code
tsx · 18 linesimport { useQuery } from '@tanstack/react-query'
function Report({ id }: { id: string }) {
const report = useQuery({
queryKey: ['report', id],
queryFn: () => fetchReport(id),
retry: 1,
retryDelay: 2000,
})
if (report.isPending) {
return report.failureCount > 0
? <p>Still trying ({report.failureCount} failed so far)...</p>
: <Spinner />
}
if (report.isError) return <ErrorBox error={report.error} />
return <ReportView report={report.data} />
}go deeper
Recall that failed queries retry three times on the client with growing delays, and that this is why an error message appears late.
Explain the backoff formula, failureCount and failureReason, the server default of zero, and how retry and retryDelay accept numbers, booleans and functions.
Show the production tuning: skip retries for 4xx, keep them for 5xx, disable retries in tests, and use failureCount to tell users an attempt already failed.
Weigh retry budgets against backend load: client retries multiply traffic during an outage, so agree defaults with the teams that own the APIs.
## The default retry policy TanStack Query v5 assumes that many failures are transient - a flaky network, a restarting backend - and **retries failed queries automatically**. Two options control this, and both have defaults: - **`retry`** - how many times to retry. Default: **`3` on the client and `0` on the server** (the retryer falls back to `0` when it detects a server environment and `3` otherwise, so server rendering does not stall on a failing request). - **`retryDelay`** - how long to wait before each retry. Default: **exponential backoff**, `Math.min(1000 * 2 ** failureCount, 30000)`, capped at 30 seconds. With the defaults, a query whose endpoint keeps answering 500 runs like this: | Attempt | Outcome | Wait before next attempt | |---|---|---| | 1 (initial) | fails | about 1 s | | 2 (retry 1) | fails | about 2 s | | 3 (retry 2) | fails | about 4 s | | 4 (retry 3) | fails | none - the query now errors | That is **four calls** and roughly **seven seconds of waiting** plus the time of each request before the UI can show an error. Nothing is wrong; the library is doing what it was told. ## What the result shows during retries While retries are pending, the query has not failed yet in the `status` sense: - `status` stays **`'pending'`** (for a first load) and `fetchStatus` stays `'fetching'`; - **`failureCount`** increments with each failed attempt and resets to `0` on success; - **`failureReason`** holds the latest error, while `error` stays `null` until the final attempt fails. This lets a UI say "still trying (attempt 2)..." without claiming the query has failed. ## Retries pause, they do not spin The retryer only continues when it is allowed to. Between attempts it checks that the window is focused and, unless `networkMode` is `'always'`, that the `onlineManager` reports a connection. If either check fails, the retry **pauses** (`fetchStatus: 'paused'`) and resumes when focus or the connection returns. A background tab therefore does not hammer a failing API. ## Tuning it Both options accept several shapes: - `retry: false` - never retry; `retry: true` - retry forever; `retry: 5` - up to five retries; - `retry: (failureCount, error) => boolean` - decide per failure. `failureCount` is `0` when the first retry is being considered; - `retryDelay: 1000` - a fixed delay; `retryDelay: (failureCount, error) => ms` - any curve you like. ```ts import { QueryClient } from '@tanstack/react-query' class HttpError extends Error { constructor(public status: number) { super(`HTTP ${status}`) } } export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { if (error instanceof HttpError && error.status < 500) return false return failureCount < 2 }, retryDelay: (failureCount) => Math.min(500 * 2 ** failureCount, 10_000), }, }, }) ``` Here a 4xx fails immediately (retrying a 404 or 403 will not change the answer), and a 5xx gets at most two retries: the function is asked with `failureCount` 0 and 1 (both true) and then 2 (false), so three attempts in total. ## Practical consequences 1. **Error states look slow in development.** Point a query at a broken endpoint and the error UI takes seconds to appear; that is the retry budget, not a slow server. 2. **Tests time out.** A test that expects an error message within the default wait of a testing library often fails because the query is still retrying. Create the test `QueryClient` with `retry: false`. 3. **Non-retryable errors waste time.** Validation or authorisation failures should short-circuit through a `retry` function. 4. **Server rendering is already safe.** With the server default of `0`, a failing prefetch fails fast instead of blocking the response. ## Recognising it in the network panel The pattern is easy to spot once you know it: the same request appears four times, spaced further apart each time, and the error UI appears only after the last one. If you see exactly one failed request and an immediate error, something has overridden the default - a `retry: false` in `defaultOptions`, a `retry` function that returned `false`, or code running on the server. If you see requests stop while the tab is in the background and resume when you return to it, that is the pause described above, not a hang. ## Scope Retry here is the query-side policy. Deciding how an error is rendered once it arrives - inline, or thrown to an ancestor with `throwOnError` - is a separate choice.
- Why does TanStack Query not retry on the server by default?During server rendering a request is waiting on the result. Three retries with backoff would add seconds to the response, so the default is `0` there; the client can still retry after hydration if the query runs again.
- What does retry: (failureCount, error) => failureCount < 3 do differently from retry: 3?Nothing in count: both allow three retries, because `failureCount` is `0` when the first retry is considered. The function form is useful only when the decision also depends on the error, such as skipping retries on a 404.
saying these in an interview costs you the question
- Believes queries do not retry unless retry is set explicitly.
- Reads retry: 3 as three attempts in total rather than three retries.
- Expects the error field to hold the failure while retries are still running.
- Assumes retries keep firing while the tab is hidden or the device is offline.
- Sets a longer test timeout instead of turning retry off in the test QueryClient.