# Retries and errors

> Retry failed requests with the retry() link, set attempts per query in meta, choose delays and which errors to retry, time requests out, and show errors.

The `retry()` link retries failed requests. With its defaults, a request that fails in the browser is retried **3 times**, waiting 1, 2 and then 4 seconds, before the query counts as failed. Readers keep suspending, or keep showing their current data, the whole time.

Nothing retries unless the client has the link, on its own or through `recommended()`:

```ts
const queryClient = createQueryClient({ links: [retry()] });
// or
const withEverything = createQueryClient({ ...recommended() });
```

Some requests are never retried:

- **Cancelled requests**, whose `signal` was aborted.
- **Requests on a server client.** The user is waiting for the HTML, and the browser can try again.

## Attempts per query

A query sets its own attempts with `meta: { retry }`: a number of retries after the first attempt, or `false` for none. It wins over the link's `attempts`:

```ts
export function postQuery(input: { slug: string }) {
	return queryOptions({
		queryKey: posts("bySlug", input.slug),
		queryFn: () => getPost(input.slug),
		meta: { retry: 5 }, // or `false`
	});
}
```

The `retry()` link declares the field, so a factory built with a bound `queryOptions` gets it typed only when the root has the link: see [Query meta](https://raf-query.katt.dev/docs/query-meta.md#typing-meta-by-its-root). On a client without the link, `meta.retry` does nothing.

## Options

`retry({ attempts, delay, when })` sets the defaults for every query of the client:

| Option     | Default                                                                                                                              |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `attempts` | `3`: retries after the first attempt                                                                                                 |
| `delay`    | Doubling from 1 second, at most 30 seconds                                                                                           |
| `when`     | Every error but a thrown [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response): whether an error is worth retrying |

`delay(attempt)` gets the retry's index, from `0`. `when(error)` is the place to skip errors that won't go away:

```ts
const patient = createQueryClient({
	links: [
		retry({
			attempts: 5,
			delay: (attempt) => 500 * (attempt + 1),
			when: (error) => !(error instanceof HttpError && error.status === 404),
		}),
	],
});
```

### Retries while offline

In the browser, a retry waits while [`navigator.onLine`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) is `false`: after the delay, it waits for the window's [`online`](https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event) event. The query counts as fetching meanwhile. The first attempt always runs.

## Timeouts

`timeout(ms)` is another link: it fails a request that takes longer than `ms` with a `QueryTimeoutError`, and aborts its `signal`. Links wrap each other in order, outermost first, so where it goes decides what it times:

```ts
// Each attempt gets 10 seconds.
const perAttempt = createQueryClient({ links: [retry(), timeout(10_000)] });

// The request and all its retries get 30 seconds.
const overall = createQueryClient({ links: [timeout(30_000), retry()] });
```

## Showing errors

What the user sees depends on whether there was data before the failure.

**The first load fails.** Reading the query throws to the nearest error boundary. Before the boundary renders its children again, start the query again the way the loader did, so the reader suspends on the new request instead of throwing the old error:

```tsx
function CommentsSection() {
	return (
		<CatchBoundary
			getResetKey={() => loaderData.comments.input.slug}
			errorComponent={CommentsError}
		>
			<Suspense fallback={<CommentsSkeleton />}>
				<Comments handle={loaderData.comments} />
			</Suspense>
		</CatchBoundary>
	);
}

function CommentsError(props: ErrorComponentProps) {
	const queryClient = useQueryClient();
	return (
		<div role="alert">
			<p>
				Couldn't load comments:{" "}
				{props.error instanceof Error
					? props.error.message
					: String(props.error)}
			</p>
			<button
				onClick={() => {
					void load(queryClient, {
						comments: prefetch(commentsQuery, loaderData.comments.input),
					});
					props.reset();
				}}
			>
				Try again
			</button>
		</div>
	);
}
```

Any error boundary works: [`CatchBoundary`](https://tanstack.com/router/latest/docs/framework/react/api/router/catchBoundaryComponent) from TanStack Router, [`react-error-boundary`](https://github.com/bvaughn/react-error-boundary), Next.js's [`error.tsx`](https://nextjs.org/docs/app/api-reference/file-conventions/error), or your own class component.

**A background refetch fails.** The existing data stays on screen: losing it because a refetch failed would be worse than showing it a little longer. The error is on the reader's state as `error`, for a hint:

```tsx
const [postThenable, controls] = useQuery(postQuery, loaderData.post);
const post = use(postThenable);

{
	controls.error ? (
		<p role="status">
			Couldn't refresh. Showing data from {formatTime(controls.updatedAt)}.
		</p>
	) : null;
}
```

Make the next request fail in the demo, then reload a post to see each case:

> [!NOTE]
> The [web version of this page](https://raf-query.katt.dev/docs/retries) has an interactive demo here. Its code: [retries.tsx](https://github.com/KATT/raf-query/blob/main/www/src/demos/retries.tsx).

## Errors from loaders

A failed `prefetch` never makes `load` reject: the error surfaces where the data is read, and only that part of the page shows it. A failed `swr` or `revalidate` makes `load` reject, so it reaches the route's error boundary (`errorComponent` in TanStack Router, `error.tsx` in Next.js), replacing the page.

This is one more reason to choose per query: wait with `swr` for what the page can't do without, and `prefetch` the rest, so a failing sidebar doesn't take the whole page down.

## One place for every error

A link sees every request of the client, so it's the place for logging and toasts. Put it before `retry()` to see each request's final error, after its retries. It can declare a `meta` field of its own, for a per-query message:

```ts
const reportErrors = link<{ meta: { errorMessage?: string } }>(
	async (op, next) => {
		try {
			return await next(op);
		} catch (error) {
			if (!op.signal.aborted) {
				reportError(error, { queryKey: op.queryKey });
				showToast(op.meta.errorMessage ?? "Something went wrong");
			}
			throw error;
		}
	},
);

const reporting = createQueryClient({ links: [reportErrors, retry()] });
```

## Redirects

In TanStack Start, a `queryFn` can throw the router's [`redirect()`](https://tanstack.com/router/latest/docs/framework/react/api/router/redirectFunction), for instance when a session has expired. `setupRouterIntegration` turns it into a navigation wherever the query failed: a loader, a render or a background refetch.

A redirect is a thrown `Response`, which `retry()` doesn't retry, so it navigates at once. A `when` of your own replaces that check, so keep it:

```ts
const withRedirects = createQueryClient({
	links: [
		retry({
			when: (error) =>
				!isRedirect(error) &&
				!(error instanceof HttpError && error.status === 404),
		}),
	],
});
```
