Error Handling
Loading and sync errors#
An app that shows synced data asks two questions:
- Can I show the data? Read status.
- Is the data on screen still being kept up to date? Read isSyncError and health.
TanStack DB answers them separately. Once a query has shown data, a network or backend failure never takes the data off the screen. It shows up in health, and the rows stay.
function TaskList() {
const {
data,
isLoading,
isLoadingError,
isCrashed,
isSyncError,
health,
collection,
} = useLiveQuery((q) =>
q
.from({ task: taskCollection })
.join({ user: userCollection }, ({ task, user }) =>
eq(task.assigneeId, user.id),
),
)
if (isLoading) return <Spinner />
if (isLoadingError) return <LoadFailed onRetry={() => collection.reload()} />
if (isCrashed) return <StorageBroken />
return (
<>
{isSyncError && (
<SyncBanner health={health} onReload={() => collection.reload()} />
)}
{data.map((row) => (
<TaskRow key={row.task.id} {...row} />
))}
</>
)
}Status#
| status | Flag | Meaning | What the app does |
|---|---|---|---|
| idle | isIdle | Sync has not started. | Nothing yet. |
| loading | isLoading | The first data is on its way. | Show a placeholder. |
| ready | isReady | The data can be shown. | Show it. Check isSyncError for a banner. |
| error | isLoadingError | The first load failed. There is nothing to show. | Show a failure screen and offer reload(). |
| crashed | isCrashed | Local storage broke. This Collection cannot be used again. | Recreate the Collection (see When a Collection crashes). |
| cleaned-up | isCleanedUp | The Collection was cleaned up. | Nothing. |
Two rules make status predictable:
- After a query has shown data, only a crash changes its status. A dropped connection, a failed refetch, or a backend that stops answering appears in health. isLoadingError therefore always means "nothing to show yet".
- A query takes the worst status of its sources until it is ready. If any source fails its first load, the query's status is error.
isError is deprecated. It is true for error and crashed. Use isLoadingError and isCrashed, which need different handling.
Sync errors: the health tree#
health has one node for the query itself and one for each source in the query, in query order. The tree's shape is fixed by the query, so you can render, log, or send it to monitoring.
{
alias: 'root', kind: 'live-query', status: 'ready', hasErrors: true,
failureReason: null, error: null, reloading: false,
sources: [
{ alias: 'task', kind: 'source', status: 'ready', hasErrors: false,
failureReason: null, error: null, reloading: false, sources: [] },
{ alias: 'user', kind: 'source', status: 'ready', hasErrors: true,
failureReason: { name: 'FetchError', message: 'Network request failed', at: 1760342400000 },
error: null, reloading: false, sources: [] },
],
}A node can show one of two problems. They follow TanStack Query's failureReason and error:
| Field | Meaning | What the app does |
|---|---|---|
| failureReason | Something went wrong, and the sync adapter is still trying. It clears by itself when sync works again. | Show "Reconnecting…" or nothing. Wait. |
| error | The sync adapter stopped. The rows from this source stay but no longer update. | Show "Updates stopped" and offer reload(). |
isSyncError is true when the query is ready and any node has a failureReason or an error. hasErrors on a node says whether that node or any node below it has one.
Each problem is { name, message, code?, at, cause }. cause holds the exact value the adapter reported, for example the Response or the error your queryFn threw. Every other field is plain JSON. A node keeps its object identity until something in its part of the tree changes, so === and useMemo work on it.
failureReason says that the adapter is trying, not that trying will help. An expired token gives a failureReason on every refetch until the user signs in. Use code or cause for cases like this:
function SyncBanner({ health, onReload }: { health: HealthNode; onReload: () => void }) {
const [first] = findProblems(health) // nodes with a problem, in query order
if (first?.failureReason?.code === '401') return <SignInAgain />
if (first?.error) return <UpdatesStopped onReload={onReload} />
return <Reconnecting />
}findProblems(health) walks the tree and returns every node with a failureReason or an error, in query order, each with its alias path. Use it for the message on a failure screen too: when isLoadingError is true, findProblems(health)[0].error is the first load failure that stopped the query.
onReload here calls the query's reload(), which reloads every source that has an error. To reload one source instead, call reload() on that source's Collection.
Collections without a live query#
Every Collection has the same surface, so code that uses a Collection directly does not need a live query:
todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ...
todos.health // one node: { status, failureReason, error, reloading, ... }
todos.on('health:change', ({ health }) => report(health))
await todos.reload()A live query is a Collection too; its health is the whole tree.
Failed requests for more data#
After a query is ready it can ask for more data: the next page of an infinite query, or rows for a newly joined key. When that request fails, the query's own node (root) shows an error. The sources stay healthy and the rows already shown stay. The error clears when the data loads, or when the query no longer needs it, for example after the user scrolls back.
const { data, health, fetchNextPage } = useLiveInfiniteQuery(
(q) =>
q
.from({ post: postCollection })
.orderBy(({ post }) => post.createdAt, 'desc'),
{ pageSize: 20 },
)
{
health?.error && (
<button onClick={() => fetchNextPage()}>Retry loading more</button>
)
}fetchNextPage() resolves either way. A reload of a source re-issues failed requests for that source.
Reloading#
collection.reload() starts a new sync run for a Collection. It works like a sync-side truncate: nothing visible changes until the new run's first commit.
- The current rows, status, and any error stay on screen. The node shows reloading: true.
- When the new run commits, its rows replace the old rows in one change, and the node's problem clears. A status of error becomes ready.
- If the new run fails before it commits, the old rows and status stay, and the node shows the new error.
On a live query, reload() reloads each of its sources that has an error.
// Retry a failed first load every 30 seconds until the backend is back.
useEffect(() => {
if (!isLoadingError) return
const id = setInterval(() => collection.reload(), 30_000)
return () => clearInterval(id)
}, [isLoadingError, collection])reload() returns a promise that settles when the new run commits or fails. Calling it while a reload is in flight returns the same promise. On a crashed Collection it throws.
Use reload() for retries the user or the app decides on. Retries inside one attempt belong to the sync adapter: for example, set retry on a Query Collection, so a short outage never reaches isLoadingError.
While a query waits on a source's reload, its status stays error. Once no source has an error, a query that is still waiting for another source's first load shows loading.
When a Collection crashes#
A Collection crashes when its local storage can no longer serve reads or accept writes:
- its IndexedDB connection closed (for example after a versionchange from another tab);
- a local SQLite persistence write or restore failed;
- an internal invariant failed.
The Collection moves to crashed, and so does every live query over it. A crash is not recovered in place: reload() throws. To recover, clean up the Collection together with everything that reads from it, then start it again.
console.log(todos.subscribers) // live queries, Effects, and raw subscriptions
await todos.cleanup({ dependents: true })
const freshTodos = createCollection(todosOptions())Cleaning up a Collection#
cleanup() throws while anything still subscribes to the Collection, because those readers would lose their data. Unmount the components that read it first, or pass { dependents: true } to clean up its live queries and Effects first. Collections that nothing reads are cleaned up automatically after gcTime.
Persisted Collections#
A persisted Collection restores its rows from local storage before the remote syncs.
- Restored rows open the Collection. If local storage holds rows from an earlier sync, the Collection becomes ready with them, even if the remote has not connected yet. Remote problems after that appear in health like any other sync problem.
- A failed remote waits for the restore. If the remote fails before the restore finishes, the Collection waits: restored rows make it ready with an error on its node; no stored rows make it error.
- An empty local store does not open the Collection. A Collection that has never synced stays loading until the remote answers, so an empty list always means "no rows", not "unknown".
- A failed restore is a crash.
persistedStatus, isPersistedReady, and persistedError are removed.
Effects#
An Effect ends when a source it reads fails its first load or crashes; it calls onSourceError(error). After its initial load, any other problem calls onHealthChange(health) with the same tree a live query returns, and the Effect keeps running.
Suspense and error boundaries (React)#
useLiveSuspenseQuery suspends while loading and throws to the nearest error boundary for error and crashed. Sync problems after the first data never reach the boundary; read health from the hook.
For adapter authors#
A sync adapter reports failures with three calls:
| Call | Meaning | Effect |
|---|---|---|
| reportError(error) | Something went wrong; I am still trying. | Sets failureReason (after the first ready). |
| fail(error) | I stopped. | Before the first ready: status: 'error'. After: the node's error; the rows stay. |
| reportHealthy() | Sync works again. | Clears failureReason. |
sync: ({
begin,
write,
commit,
markReady,
reportError,
fail,
reportHealthy,
}) => {
let ready = false
const stream = connect({
onData: (rows) => {
begin()
rows.forEach((row) => write({ type: 'insert', value: row }))
commit()
if (ready) reportHealthy()
else markReady()
ready = true
},
onError: (error, { willRetry }) => {
if (!willRetry) fail(error)
else if (ready) reportError(error)
},
})
return () => stream.close()
}- Retry inside the first load before calling fail. While you retry before the first ready, call nothing; the Collection stays loading.
- These calls follow the Collection's state machine, and a call that does not fit it throws: reportError or reportHealthy before the first ready, markReady after fail, and any call after the sync run has ended. reportHealthy when nothing is wrong is allowed and changes nothing, so you can call it after every commit.
- An error from fail clears only through reload(), which calls your sync function again for a new run.
Migrating#
| Before | After |
|---|---|
| isError | isLoadingError (first load failed) or isCrashed (storage broke) |
| status: 'error' after a crash | status: 'crashed' |
| markError(error) | fail(error) |
| reportError(error, { final }) | reportError(error) or fail(error) |
| node phase, final, isError | node failureReason or error |
| cleanup() then preload() to recover | reload() |
| cleanup() with live queries mounted | throws; unmount first or cleanup({ dependents: true }) |
| persistedStatus, isPersistedReady, persistedError | status and health |
| query-db utils.lastError, isError, errorCount, clearError() | health, reload() |