Guides
Retries and errors
Retry failed requests with counts, predicates or Schedules, choose delays, and show errors with error boundaries.
When a request fails in the browser, raf-query retries it 3 times, waiting 1, 2 and then 4 seconds, before treating the query as failed. Readers keep suspending, or keep showing their current data, the whole time.
export function function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery(input: {
slug: string;
}
input: { slug: stringslug: string }) {
return queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
queryOptions({
queryKey: readonly ["post", string]queryKey: ["post", input: {
slug: string;
}
input.slug: stringslug],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>
queryFn: () => function getPost(slug: string): Promise<Post>getPost(input: {
slug: string;
}
input.slug: stringslug),
retry?: RetryPolicy | undefinedretry: 5, // or false, true, a predicate, or a Schedule
});
}
retry | Retries |
|---|---|
3 (default in the browser) | Up to that many times |
0 or false (default on the server) | Never |
true | Forever |
(failureCount, error) => boolean | While it returns true. failureCount starts at 1 |
A Schedule | As the Effect schedule decides |
Server renders never retry by default: the user is waiting for the HTML, and a failure can be retried by the browser instead.
A predicate is the place to skip retries for errors that won't go away:
queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", "hello"];
}>>
queryOptions({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>
queryFn: () => function getPost(slug: string): Promise<Post>getPost("hello"),
retry?: RetryPolicy | undefinedretry: (failureCount: numberfailureCount, error: unknownerror) =>
failureCount: numberfailureCount <= 3 && !(error: unknownerror instanceof class HttpErrorHttpError && error: HttpErrorerror.HttpError.status: numberstatus === 404),
});Set it once for the app with config.defaults:
const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({
Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.retry?: RetryPolicy | undefinedretry: 1 },
});Retry delay
retryDelay sets the wait before each retry. The default doubles from one second, up to 30 seconds. Pass a number for a constant delay, or a function:
queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", "hello"];
}>>
queryOptions({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>
queryFn: () => function getPost(slug: string): Promise<Post>getPost("hello"),
// Constant
retryDelay?: number | ((failureCount: number, error: unknown) => number) | undefinedretryDelay: 1000,
});
queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", "hello"];
}>>
queryOptions({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", "hello"];
services: never;
}>
queryFn: () => function getPost(slug: string): Promise<Post>getPost("hello"),
// Exponential with a ceiling, the default's shape
retryDelay?: number | ((failureCount: number, error: unknown) => number) | undefinedretryDelay: (failureCount: numberfailureCount) =>
var Math: MathMath.Math.min(...values: number[]): numbermin(1000 * 2 ** (failureCount: numberfailureCount - 1), 30_000),
});
Retries while offline
In the browser, retries pause while navigator.onLine is false: after the delay, the next attempt waits for the window's online event. The request counts as fetching meanwhile. The first attempt always runs, since navigator.onLine can't be trusted to be right.
Showing errors
What the user sees depends on whether there was data before the failure.
The first load fails. The query's promise rejects, so use() throws to the nearest error boundary. Resetting that boundary re-renders the reader, which refetches the query:
<class CatchBoundaryCatchBoundary
getResetKey: () => unknowngetResetKey={() => const post: {
hash: string;
}
post.hash: stringhash}
errorComponent?: ErrorRouteComponent | undefinederrorComponent={(props: ErrorComponentPropsprops) => (
<React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div React.HTMLAttributes<HTMLDivElement>.role?: React.AriaRole | undefinedrole="alert">
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
Couldn't load comments:{" "}
{props: ErrorComponentPropsprops.error: unknownerror instanceof var Error: ErrorConstructorError
? props: ErrorComponentPropsprops.error: Errorerror.Error.message: stringmessage
: var String: StringConstructor
(value?: any) => string
String(props: ErrorComponentPropsprops.error: unknownerror)}
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
<React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={props: ErrorComponentPropsprops.reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
</React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
)}
>
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): ReactNodeCommentsSkeleton />}>
<function Comments(props: {
readonly handle: QueryHandle<typeof commentsQuery>;
}): ReactNode
Comments handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
handle={const loaderData: {
comments: QueryHandle<typeof commentsQuery>;
}
loaderData.comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense>
</class CatchBoundaryCatchBoundary>
Any error boundary works: CatchBoundary from TanStack Router, react-error-boundary, Next.js's error.tsx, 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 controls as error, for a hint:
const [const post: Postpost, const postState: QueryControls<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
postState] = useQuery<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, undefined>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, const loaderData: {
post: QueryHandleWithData<typeof postQuery>;
}
loaderData.post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post);
{
const postState: QueryControls<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
postState.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.error: unknownerror ? (
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p React.HTMLAttributes<HTMLParagraphElement>.role?: React.AriaRole | undefinedrole="status">
Couldn't refresh. Showing data from {function formatTime(value: number): stringformatTime(const postState: QueryControls<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
postState.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.updatedAt: numberupdatedAt)}.
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
) : null;
}Make the next request fail in the demo, then reload a post to see each case:
Errors from loaders
A failed prefetch never throws out of the loader: the error surfaces where the data is read, and only that part of the page shows it. A failed ensure or fetch rejects, 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: ensure 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
config.onError is called for every failed request, after its retries, whether or not anything shows it. It's the place for logging and toasts:
const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({
QueryClientConfig<never>.onError?(error: unknown, query: Query): voidonError: (error: unknownerror, query: Query<QueryFactory>query) => {
function reportError(error: unknown, context: {
readonly key: unknown;
}): void
reportError(error: unknownerror, { key: unknownkey: query: Query<QueryFactory>query.Query<QueryFactory>.key: QueryKeykey });
},
});Combine it with query meta for a per-query message.
Redirects
In TanStack Start, a queryFn can throw the router's redirect(), 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. Redirects aren't retried.