How to Handle fetchQuery() Errors in TanStack Query
By Nark Team
Wrap every queryClient.fetchQuery() call in a try-catch block. Unlike useQuery, which returns errors in a result.error property, fetchQuery throws the error directly. Without a try-catch, the error propagates up the call stack and can crash an SSR render, surface as an unhandled promise rejection, or freeze a route loader. The TanStack Query documentation states this in one sentence: fetchQuery "will either resolve with the data or throw with the error."
Quick Answer: Wrap
queryClient.fetchQuery()in try-catch at every call site, OR registernew QueryCache({ onError })globally so all queries route their rejections through one handler. To check your codebase for unhandled fetchQuery calls automatically:npx nark --tsconfig ./tsconfig.json
Why does fetchQuery throw when useQuery doesn't?
useQuery and fetchQuery solve different problems. useQuery is a React hook that owns its own render lifecycle, so it surfaces errors as state ({ data, error, isError, isLoading }) that components read on the next render. fetchQuery is an imperative method on QueryClient. It returns a Promise<TData>. There is no render to attach error state to, so it follows the only convention a Promise<TData> can follow: resolve with TData on success, reject (throw) on failure.
The two APIs look symmetric, but their error contracts are opposite:
| Method | Returns | On error | Where you handle it |
|---|---|---|---|
useQuery(...) | { data, error, isError, ... } | Populates error field, does NOT throw | Conditional render based on isError |
queryClient.fetchQuery(...) | Promise<TData> | Rejects (throws) | try-catch around the await |
queryClient.prefetchQuery(...) | Promise<void> | Resolves silently (logs only) | Nothing required; safe to call |
useSuspenseQuery(...) | { data } | Throws to nearest Suspense errorBoundary | React Suspense + ErrorBoundary |
The third row is what catches teams off guard. prefetchQuery looks similar to fetchQuery (same QueryClient, same options shape) but is intentionally fire-and-forget. If you swap prefetchQuery for fetchQuery to get back the data, you also opt into its throwing contract, and any try-catch you forgot to add is now a latent crash.
What does an unhandled fetchQuery error actually break?
Three blast radii depending on the call site.
1. SSR render (Next.js getServerSideProps, App Router server components, Remix loaders). The server-side render throws. The framework converts this into a 500 response or a build-time failure. End users see a blank page or an unstyled error template. The error logs land in your platform's server logs, which is the only place anyone will see them.
// Next.js App Router server component
export default async function UserPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// If the user does not exist or the API is down, this throws.
// Next.js renders the nearest error.tsx (if defined) or returns 500.
const user = await queryClient.fetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id),
});
return <UserDetail user={user} />;
}
2. Route loader (TanStack Router, Remix). The loader rejects. The router catches the rejection and routes the user to the route's errorComponent (TanStack Router) or ErrorBoundary (Remix). This is the only context where an unhandled fetchQuery is fully covered by the framework, and only because the framework explicitly catches loader rejections.
3. Imperative client code (event handlers, store actions, hooks that call fetchQuery directly). The promise rejects. If no caller awaits it inside a try-catch, Node.js or the browser eventually fires an unhandled promise rejection. In a browser, this surfaces in DevTools but does not stop the app. In Node.js (e.g. an Electron renderer, an SSR worker, or a serverless function), it can terminate the process depending on the runtime's unhandledRejection mode.
How to wrap fetchQuery in try-catch (the per-call-site fix)
The mechanical answer. Every await queryClient.fetchQuery(...) gets a try-catch. The catch block decides what the surrounding context can do with the error.
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient();
async function loadUser(userId: string) {
try {
return await queryClient.fetchQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
} catch (error) {
// Decide based on caller context:
// SSR: return { notFound: true } or redirect
// Route loader: throw new Response('Not Found', { status: 404 })
// Client store: log, surface a UI error state, or rethrow
console.error('Failed to load user', userId, error);
throw error;
}
}
The catch block is not optional. Even when you intend to rethrow, the catch documents that this site is a known throw site. A future reader (or a future static analyzer) can tell the difference between "the author considered errors here" and "the author forgot."
For SSR specifically, the canonical TanStack pattern catches the error and returns a redirect or notFound result:
// Next.js Pages Router
export async function getServerSideProps(context) {
const queryClient = new QueryClient();
try {
await queryClient.fetchQuery({
queryKey: ['user', context.params.id],
queryFn: () => fetchUser(context.params.id),
});
} catch (error) {
return { redirect: { destination: '/404', permanent: false } };
}
return { props: { dehydratedState: dehydrate(queryClient) } };
}
How to register a project-wide error handler with QueryCache(onError)
For codebases that prefer one place for cross-cutting concerns (logging, Sentry, toast notifications), TanStack Query exposes QueryCache and MutationCache constructors that accept global onError callbacks. Every query rejection routes through these.
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import * as Sentry from '@sentry/react';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
Sentry.captureException(error, {
tags: { source: 'react-query', queryKey: JSON.stringify(query.queryKey) },
});
},
}),
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
Sentry.captureException(error, {
tags: { source: 'react-query-mutation', mutationKey: JSON.stringify(mutation.options.mutationKey) },
});
},
}),
});
This handler runs for every query and mutation rejection, including those triggered by fetchQuery and fetchMutation. The handler does NOT replace per-call-site error handling. It supplements it. The promise still rejects from the caller's perspective. The handler just guarantees one observability call site instead of N.
The combined pattern, which most production codebases settle on:
- Register a
QueryCache({ onError })andMutationCache({ onError })for logging and observability. - Still wrap every
await queryClient.fetchQuery(...)in try-catch for the call-site decision (redirect? toast? show error state?). - Let the global handler be the single point for things the call site does not need to know about (Sentry, structured logging, replay session linking).
A real example: three fetchQuery callsites in an Electron client
This is what the pattern looks like when it lives in a real codebase. The chatbox project (chatboxai/chatbox, an open-source desktop LLM client built on Electron + React) has three queryClient.fetchQuery() callsites that surface the API trap cleanly. The patterns are typical of any data layer that uses TanStack Query as a request memoizer.
Pattern 1: a hook that returns a callable. A custom hook returns a function. The function calls fetchQuery and returns the result.
// src/renderer/hooks/useBlob.ts:19-27
export function useFetchBlob() {
const queryClient = useQueryClient()
return useCallback(
(key: string) =>
queryClient.fetchQuery({
queryKey: ['blob', key],
queryFn: () => storage.getBlob(key).catch(() => null),
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
}),
[queryClient]
)
}
The .catch(() => null) on the storage.getBlob(key) call swallows storage errors and returns null. That is the only error handling here. Any error fetchQuery throws for an unrelated reason (cache eviction race, internal QueryClient failure, etc.) still propagates out of the returned callable. Every caller of useFetchBlob() is implicitly required to handle the throw.
Pattern 2: a thin wrapper function exposed as a module API. A free function in a store calls fetchQuery and returns its result.
// src/renderer/stores/chatStore.ts:64-66
export async function listSessionsMeta() {
return await queryClient.fetchQuery(listSessionsMetaQueryOptions)
}
The function is a one-liner over a queryOptions object. No try-catch. Anywhere this is called from must wrap it, or the throw escapes to wherever the call chain ends.
Pattern 3: the same shape, parameterized. A second store helper with the same pattern.
// src/renderer/stores/chatStore.ts:113-115
export async function getSession(sessionId: string) {
return await queryClient.fetchQuery(getSessionQueryOptions(sessionId))
}
All three callsites are throw sites with no per-call-site try-catch. Whether the throws ever escape to an unhandled rejection depends on what the surrounding runtime catches. The chatbox project has an ErrorBoundary.tsx at src/renderer/components/common/, which catches errors thrown during React render or in component lifecycle methods. That is what React error boundaries do, by design. They do NOT catch async promise rejections from event handlers, store actions, or callables returned from hooks. The React documentation is explicit on this point: "Error boundaries do not catch errors for ... asynchronous code."
This is the interesting edge of static analysis. The scanner has to decide what to flag.
The nark scanner detects these three sites, sees that the project has an ErrorBoundary.tsx plus several other potential project-level signals, and treats the presence of any one signal as evidence that the project has opted into a global error path. On that basis it suppresses the per-call-site warning. This is a heuristic, not a proof. It optimizes for a low false-positive rate (don't warn the author of a project that has clearly thought about errors). The trade-off is that it can be over-confident: an ErrorBoundary is a real signal that the project cares about errors, but it is not an actual catch for useFetchBlob()'s returned callable, which fires from a non-render context.
The three chatbox patterns are a clean illustration of why the per-call-site try-catch matters even when a project has an ErrorBoundary. The patterns themselves are common. A hook that returns a callable, a one-liner store helper, a parameterized variant of the helper. All three are typical of how this API appears in production codebases. None of them are bugs in chatbox's design (the project may handle these throws elsewhere, or the Electron runtime may absorb them at the process level, or the patterns may never reject in practice given chatbox's storage layer). They are useful precisely because they are normal. A reader can recognize the shape in their own code.
Wrong way vs right way
The pattern that causes production crashes. A bare imperative fetchQuery with no surrounding handler:
// WRONG: no try-catch, no project-level handler, throws crash the surrounding context
async function loadDashboard(userId: string) {
const user = await queryClient.fetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const orders = await queryClient.fetchQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId),
});
return { user, orders };
}
If either fetch throws, the function rejects. The SSR render fails. An imperative caller sees an unhandled rejection. The await sequence short-circuits, so orders is never reached even if you would have wanted to render the user with a partial result.
The correct version handles each await and decides what the function should return on each failure:
// RIGHT: per-call-site try-catch + decision about partial state
async function loadDashboard(userId: string) {
let user;
try {
user = await queryClient.fetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
} catch (error) {
// User is required for the dashboard; bail.
throw new DashboardLoadError('user', userId, error);
}
let orders;
try {
orders = await queryClient.fetchQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId),
});
} catch (error) {
// Orders are optional; render the dashboard with an empty state.
console.warn('Failed to load orders for dashboard', userId, error);
orders = [];
}
return { user, orders };
}
Two awaits, two catches, two different recovery strategies. The wrong version cannot express the distinction.
fetchQuery error handling checklist
The TanStack Query docs commit to one rule: fetchQuery either resolves with the data or throws. Everything below follows from that.
- Wrap every
await queryClient.fetchQuery(...)in try-catch. No bare awaits. - Register
new QueryCache({ onError })andnew MutationCache({ onError })globally for logging, Sentry, or any observability concern that crosses every query. - Decide what the catch block should do per context. SSR catches return redirect/notFound. Route loaders rethrow
Responseobjects. Client code surfaces error state, retries, or rethrows. - Distinguish
fetchQueryfromprefetchQuery.prefetchQuerynever throws.fetchQueryalways can. If you do not need the return value, use prefetch. - Distinguish
fetchQueryfromuseQuery.useQueryputs errors onresult.error.fetchQueryputs errors on the rejected promise. Code that grew from a useQuery refactor often forgets the switch. - Wrap
useSuspenseQueryconsumers in an ErrorBoundary. Suspense queries throw to the nearest boundary, so the boundary IS the error handler. Without one, the error bubbles to the root. - For SSR specifically, return a redirect or notFound from the catch block. A rethrow from
getServerSidePropsis a 500 to the user. - Test the catch block. A try-catch that swallows the error silently (no log, no rethrow, no state change) is worse than no catch at all. It hides crashes that should be visible.
- Run
npx narkto find the call sites you missed. Imperative fetchQuery calls hide in store helpers, hooks-returning-callables, and one-line module exports.
How to automatically check fetchQuery error handling in your codebase
fetchQuery calls hide in places code review does not naturally reach: store helper functions, hooks that return callables, SSR pages, route loaders. Manually auditing them in a large codebase is not practical.
Nark is an open-source TypeScript static analyzer that reads each function call's AST context and reports unhandled fetchQuery patterns as violations. The @tanstack/react-query Nark Profile defines postconditions for the imperative QueryClient methods: fetchQuery, fetchInfiniteQuery, prefetchQuery (informational), and the corresponding mutation methods.
npx nark --tsconfig ./tsconfig.json
Nark detects:
- Bare
await queryClient.fetchQuery(...)calls with no surrounding try-catch. fetchQuerycalls inside Next.jsgetServerSidePropsor App Router server components with no error handler (the SSR-uncaught variant, which is the highest severity).useSuspenseQuerycalls in a component tree with no ancestor ErrorBoundary.- Mutation analogs:
mutationFndeclarations whose rejection paths are not wired into either a per-call handler or aMutationCache({ onError }).
Nark also reasons about project-level signals before reporting a violation. If it detects a QueryCache({ onError }), a MutationCache({ onError }), an ErrorBoundary.tsx file, or a v4 setLogger({ error }) override, it treats the project as having opted into a global error path and downgrades or suppresses the per-call-site report. These signals are not all equivalent. QueryCache({ onError }) is a real catch for every query rejection. An ErrorBoundary catches render-time throws but not async rejections from outside the render tree. Nark treats both as signals because the alternative (warning on every fetchQuery in a project that has clearly thought about errors) produces too many false positives to be useful in practice. The trade-off is real, and the choice is debatable. The rules are open for inspection in the public nark-corpus Profile for @tanstack/react-query.
To opt out of the heuristic suppression for stricter checking, configure Nark to treat all project-level signals as informational rather than authoritative, or address each call site directly with a try-catch (which is the only proof, as opposed to signal, that a fetchQuery throw is handled).
The scanner is open source (AGPL-3.0). The reasoning rules live in the public nark-corpus Profile for @tanstack/react-query, which links to the original TanStack documentation for every postcondition.
Frequently Asked Questions
Does fetchQuery throw or return an error?
It throws. fetchQuery returns Promise<TData>, which resolves with the fetched data on success and rejects on failure. This is the opposite of useQuery, which returns { data, error, isError, isLoading, ... } and never throws.
What is the difference between fetchQuery and prefetchQuery?
fetchQuery returns the data and throws on error. prefetchQuery returns Promise<void>, populates the cache as a side effect, and silently logs errors without throwing. Use prefetchQuery when you only need the cache populated (typical for SSR hydration). Use fetchQuery when you need the value back, and accept the try-catch obligation that comes with it.
Does useSuspenseQuery throw the same way as fetchQuery?
Yes, but to a different audience. useSuspenseQuery throws synchronously during render, which React's Suspense + ErrorBoundary machinery catches. The handler for useSuspenseQuery errors is the surrounding <ErrorBoundary> component, not a try-catch. A useSuspenseQuery with no ErrorBoundary ancestor produces a runtime crash on first error.
Will a QueryCache(onError) handler stop fetchQuery from throwing?
No. The onError callback runs in addition to the promise rejection. The caller still sees the rejected promise. Treat the global handler as a logging hook, not a substitute for try-catch. The two patterns work together, not as alternatives.
How does fetchQuery interact with React error boundaries?
By default, an unhandled rejection from fetchQuery does NOT trigger a React error boundary. Error boundaries catch errors thrown during render or in lifecycle methods. A promise rejected in an event handler or a store action bypasses the boundary entirely. To route fetchQuery errors into a boundary, either throw the error during render (e.g. inside a useSuspenseQuery) or catch it manually and call setState(() => { throw error }) to convert the async error into a render-time error.
Does this also apply to fetchInfiniteQuery?
Yes. fetchInfiniteQuery has the same throwing contract as fetchQuery. It returns Promise<InfiniteData<TData>>, resolves on success, and rejects on any error from any of the pages it fetches. The same try-catch and global handler rules apply.
Is fetchQuery safe inside a TanStack Router loader?
Mostly. TanStack Router catches rejected loader promises and routes the user to the route's errorComponent. So a fetchQuery inside createRoute({ loader: async () => ... }) is framework-handled. A defensive try-catch is still useful when you want to distinguish error types (e.g., a 404 routing to a notFound page versus a 500 routing to a generic error page), but it is not required for the app to recover.
Try It Now
npx nark --tsconfig ./tsconfig.json
Nark checks 160+ packages, including @tanstack/react-query, for correct error handling and resource cleanup. The @tanstack/react-query Profile covers useQuery, useMutation, fetchQuery, fetchInfiniteQuery, prefetchQuery, the suspense variants, and the imperative QueryClient cache methods, with project-level signal detection for QueryCache({ onError }), MutationCache({ onError }), ErrorBoundary files, and setLogger v4 overrides.