Skip to content
Guides

Error Handling

TanStack DB provides comprehensive error handling capabilities to ensure robust data synchronization and state management. This guide covers the built-in error handling mechanisms and how to work with them effectively.

Error Types

TanStack DB provides named error classes for better error handling and type safety. All error classes can be imported from @tanstack/db (or more commonly, the framework-specific package e.g. @tanstack/react-db):

ts
import {
  SchemaValidationError,
  CollectionInErrorStateError,
  DuplicateKeyError,
  MissingHandlerError,
  TransactionError,
  // ... and many more
} from "@tanstack/db"

SchemaValidationError

Thrown when data doesn't match the collection's schema during insert or update operations:

ts
import { SchemaValidationError } from "@tanstack/db"

try {
  todoCollection.insert({ text: 123 }) // Invalid type
} catch (error) {
  if (error instanceof SchemaValidationError) {
    console.log(error.type) // 'insert' or 'update'
    console.log(error.issues) // Array of validation issues
    // Example issue: { message: "Expected string, received number", path: ["text"] }
  }
}

The error includes:

  • type: Whether it was an 'insert' or 'update' operation
  • issues: Array of validation issues with messages and paths
  • message: A formatted error message listing all issues

When schema validation occurs:

Schema validation happens only for client mutations - when you explicitly insert or update data:

  1. During inserts - When collection.insert() is called
  2. During updates - When collection.update() is called

Schemas do not validate data coming from your server or sync layer. That data is assumed to already be valid.

ts
const schema = z.object({
  id: z.string(),
  created_at: z.string().transform(val => new Date(val))
  // TInput: string, TOutput: Date
})

// Validation happens here ✓
collection.insert({
  id: "1",
  created_at: "2024-01-01"  // TInput: string
})
// If successful, stores: { created_at: Date }  // TOutput: Date

For more details on schema validation and type transformations, see the Schemas guide.

Query Collection Error Tracking

Query Collections report failures through the health tree, like every other Collection. A queryFn failure before the first successful result fails the first load: the status is error, and preload() rejects with the cause. Set retry so that a short outage is retried inside that first load. After the first result, a failed refetch sets the source node's failureReason, because TanStack Query fetches again. The rows stay, and the next successful result clears it:

tsx
import { createCollection } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { useLiveQuery } from "@tanstack/react-db"

const syncedCollection = createCollection(
  queryCollectionOptions({
    queryClient,
    queryKey: ["synced-data"],
    queryFn: fetchData,
    getKey: (item: Item) => item.id,
    retry: 3,
  })
)

function DataList() {
  const { data, isSyncError } = useLiveQuery((q) =>
    q.from({ item: syncedCollection })
  )

  return (
    <>
      {isSyncError && (
        <Alert>
          Unable to sync. Showing cached data.
          <button
            onClick={() => syncedCollection.utils.refetch({ throwOnError: true })}
          >
            Retry
          </button>
        </Alert>
      )}
      {/* Render data */}
    </>
  )
}

utils.refetch({ throwOnError: true }) retries and rejects when the retry fails. The failureReason stays visible while the retry is pending. During a mutation handler, the promise can resolve at the fetch boundary before the result applies and clears it.

Incremental Subset Load Errors

An incremental loadSubset failure does not discard rows that are already available or put the shared source collection into error. A loadSubset that throws synchronously fails the same way as one that rejects: neither requestSnapshot() nor setWindow() throws, and the error arrives as a rejection. The failure belongs to the subscription that requested that subset:

ts
const subscription = todoCollection.subscribeChanges(handleChanges, {
  includeInitialState: false,
})

subscription.on('loadSubset:error', ({ error, options }) => {
  console.error('Subset failed', options, error)
})

subscription.requestSnapshot()

// The most recent failure remains available for diagnostics.
console.log(subscription.lastError)

For ordered live queries, utils.setWindow() rejects with the same error. The last failure is also available as utils.lastSubsetError, while the last successful snapshot remains readable:

ts
try {
  await liveTodos.utils.setWindow({ offset: 0, limit: 100 })
} catch (error) {
  console.error(liveTodos.utils.lastSubsetError)
}

Effects report subset failures through onSourceError and dispose because their incremental result can no longer be kept complete.

A sync adapter's unloadSubset must not throw. If it does, TanStack DB reports the error as an uncaught error and continues as if the release had succeeded. The error is not a subset failure: it does not set lastSubsetError, reject setWindow(), dispose an Effect, or make dispose() or cleanup() reject.

When a source change invalidates an ordered window, automatic full-source repair keeps the last complete snapshot visible. A failed repair exposes utils.lastSubsetError and retries at most twice, after 250 ms and 500 ms. Exhausting those retries does not put an already-ready query into a terminal error state or clear its rows. The app can show the error and explicitly retry with setWindow(). Cleanup or truncate cancels the old repair timer. Failed imperative window moves and initial loads do not use this background retry.

For SQLite-persisted on-demand collections, a failed upstream loadSubset rejects even when hydration succeeded. Cached rows remain readable; their availability does not mean the remote request succeeded. Background coordinator retry, where supported, does not change the failed caller's outcome.

When a must-refetch truncate cannot reload every active subset, a subscription keeps its last successful snapshot and reports the subset error. A live query shows it as an error on its own node until a later replay succeeds. It discards the incomplete replay batch and keeps later source changes private because they cannot prove a complete replacement. The next truncate retries every active subset. Overlapping truncates form one atomic replay: a newer truncate aborts the older attempt's requests, and an aborted request is over, so the replay does not wait for it. The newest attempt decides the result, and subscribers receive the replacement only when that attempt succeeds. Cleanup rejects window moves that are waiting for replay with AbortError.

Loading and Sync Errors

An app that shows synced data asks two questions:

  1. Can I show the data? Read status.
  2. 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.

tsx
import { eq, findProblems } from "@tanstack/db"
import { useLiveQuery } from "@tanstack/react-db"

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

statusFlagMeaningWhat the app does
idleisIdleSync has not started.Nothing yet.
loadingisLoadingThe first data is on its way.Show a placeholder.
readyisReadyThe data can be shown.Show it. Check isSyncError for a banner.
errorisLoadingErrorThe first load failed. There is nothing to show.Show a failure screen and offer reload().
crashedisCrashedLocal storage broke. Nothing recovers it in place.Clean it up with its dependents and start it again (see When a Collection crashes).
cleaned-upisCleanedUpThe 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, and preload() rejects with the cause.

isError is removed. 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. A disabled query has no query definition, so its health is undefined.

ts
{
  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,
        errors: [{ 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:

FieldMeaningWhat the app does
failureReasonSomething went wrong, and the sync adapter is still trying. It clears by itself when sync works again.Show "Reconnecting…" or nothing. Wait.
errorThe sync adapter stopped, or the first load failed. 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, errors }. cause holds the exact value the adapter reported, for example the Response or the error your queryFn threw. Every other field is plain JSON. The problem's own fields are its newest failure; errors lists every failure of the problem, oldest first (see Every failure of a problem). A node keeps its object identity until something in its part of the tree changes, so === and useMemo work on it. Server-side rendering does not transfer health; the client builds its own tree from its own sync.

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:

tsx
import { findProblems, type HealthNode } from "@tanstack/db"

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 that has a problem of its own, in query order. Use it for the message on a failure screen too: when isLoadingError is true, the first node it returns holds the first load failure in its error.

A Collection used twice in one query, for example in a self-join, has one node per alias. An include has one node, which shows the include's source; its per-row child Collections are not in the tree. A nested live query's node carries its own sources.

Every failure of a problem

A problem can have more than one failure. Its message, cause, and at are the newest failure's, so code that reads only those keeps working. errors lists each failure, oldest first, as { name, message, code?, at, cause }:

  • A source's failureReason lists each reportError() of the sync run since it began, for example each failed refetch of a query Collection. One error value is one failure: a value that is already listed keeps its place and its at. reportHealthy(error) removes that value's failure, for example when one of several failing queries recovers, and the other failures stay. reportHealthy() ends the list, and a reload() whose new sync run reports starts a new list.
  • An error from fail(), a failed first load, or a crash has one failure.
  • A query's own error lists every failed request for more data that is still failing. A request that loads, or that the query no longer needs, leaves the list. Two requests that fail with the same error are one entry. When a source's reload cannot load the query's data again, the list has every load that failed.

Adding or removing a failure is a change: the node is a new object and health:change fires.

tsx
function RetryLog({ health }: { health: HealthNode }) {
  const problem = health.sources[0]?.failureReason
  if (!problem) return null
  return (
    <details>
      <summary>
        Reconnecting: {problem.message} ({problem.errors.length} failures)
      </summary>
      <ul>
        {problem.errors.map((failure, index) => (
          <li key={index}>{failure.message}</li>
        ))}
      </ul>
    </details>
  )
}

Collections without a live query

Every Collection has the same surface, so code that uses a Collection directly does not need a live query:

ts
todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ...
todos.health // one node: { status, failureReason, error, reloading, ... }
todos.on("health:change", ({ collection }) => report(collection.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. When several requests fail, the node shows the newest, and its errors lists every request still failing. A request that TanStack DB cancels, for example because the window moved on, is over: its late result changes nothing. When a source's reload or truncate loads the query's data again, that replay decides the query's failed requests: its success clears them all, and its failure replaces them with the replay's own failures.

tsx
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>
  )
}

Reloading

collection.reload() starts a new sync run for a Collection. Nothing visible changes until the reload completes.

  • The current rows, status, and any error stay on screen. The node shows reloading: true.
  • The new run's writes apply on the rows the Collection keeps. The new run replaces every row only with a snapshot, a transaction its sync adapter begins with begin({ snapshot: true }), or with the adapter's own truncate(). Then the new rows replace the old rows in one change.
  • The reload completes when that replacement applies, or when the new run calls markReady(). A reload without a snapshot keeps the rows. At completion the node drops the old run's problems. A Collection in error becomes ready when the new run calls markReady().
  • If the new run fails, the old rows and status stay, the node shows the new error, and the reload() promise rejects.
  • A reload keeps every row its new run does not rewrite. Core never deletes rows on its own after a reload: a row the backend deleted goes when the sync adapter sends a snapshot, a delete, or its own cleanup of that row (#2269).
  • A live query's own reload keeps its rows and its window (the offset and limit it settled on) until its new run has a complete result, then shows that result in one change.
  • During a reload, health shows the new run's own problems as they happen: a failure it reports shows at once, and its recovery clears the old failure reason.

On a live query, reload() reloads each of its sources that has an error, then the query itself if its own node has an error: its first load failed, or a request for more data failed. To reload one source instead, call reload() on that source's Collection; a query in error stays there until its own reload(). A source's reload also loads the query's data again, so it clears all of the query's failed requests when that replay succeeds.

ts
// Retry a failed first load every 30 seconds until the backend is back.
useEffect(() => {
  if (!isLoadingError) return
  const id = setInterval(() => collection.reload().catch(() => {}), 30_000)
  return () => clearInterval(id)
}, [isLoadingError, collection])

Calling reload() while a reload is in flight returns the same promise. On a crashed Collection it rejects with CollectionReloadCrashedError.

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.

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;
  • its sync function threw, or an internal invariant failed.

The Collection moves to crashed, and so does every live query and Effect over it, even after they were ready. A crash cannot be recovered in place: nothing restarts it, and reload() rejects. To recover, clean up the Collection together with everything that reads from it. Cleanup discards all of its state, so the next preload() or subscriber starts it fresh, as a new Collection would. If the cause remains, for example a bug in the sync function, the fresh start crashes again.

ts
console.log(todos.subscribers) // live queries, Effects, and raw subscriptions
await todos.cleanup({ dependents: true })
await todos.preload() // a fresh start; mount the queries again

Cleaning up a Collection

cleanup() rejects with CollectionHasSubscribersError while a live query with subscribers or an Effect still reads the Collection, because those readers would lose their data. Unmount the components that read it first, or pass { dependents: true } to end its live queries and Effects first. Raw subscribeChanges() subscriptions do not block cleanup and are not ended by it. collection.subscribers lists every dependent with its kind. 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. By default it becomes ready only through its sync adapter. With initialRender: { strategy: "network-first", networkTimeoutMs }, restored rows make it ready once the deadline passes or the remote fails its first load. An empty restore keeps it loading, and a failed restore crashes it. See Render after SQLite restore.

Only a local storage failure crashes a persisted Collection. A remote subset request that the persistence layer makes for the sync adapter can also fail: acquiring, releasing, refreshing, or reacquiring a subset through the coordinator. The source keeps syncing, so on a ready Collection the failure shows as health.failureReason, and the status and rows do not change. The next remote request that succeeds clears those failures. Before the first ready it is only logged, and a load that waited on the request rejects with its error. An unloadSubset that throws or rejects is reported uncaught, as it is without persistence. After reload(), a late failure of the retired sync run's requests does not reach the new run.

Effects

An Effect ends when a source it reads fails its first load or crashes, or when a load it needs fails; it calls onSourceError(error). After its initial load, a sync problem on a source calls onHealthChange(health) with the same tree a live query returns, and the Effect keeps running.

ts
createEffect({
  query: (q) => q.from({ order: orders }),
  onBatch: (events) => {
    /* ... */
  },
  onHealthChange: (health) => {
    if (health.hasErrors) pauseAutomations()
    else resumeAutomations()
  },
})

Using Suspense and Error Boundaries (React)

useLiveSuspenseQuery suspends while loading and throws an Error to the nearest error boundary for error. The Error is the first load's cause when that cause is an Error. Otherwise it is an Error with the problem's message (health.error.message) whose cause holds the value the sync adapter reported. It throws for crashed at any time, by the same rule. Sync problems after the first data never reach the boundary; read health from the hook, as in TanStack Query.

tsx
import { useLiveSuspenseQuery } from "@tanstack/react-db"
import { Suspense } from "react"
import { ErrorBoundary } from "react-error-boundary"

const TodoList = () => {
  const { data, health } = useLiveSuspenseQuery((query) =>
    query.from({ todos: todoCollection })
  )

  // data is always defined here, so any problem in the tree is a sync problem
  return (
    <>
      {health?.hasErrors && <div>Todos may be out of date.</div>}
      {data.map((todo) => (
        <div key={todo.id}>{todo.text}</div>
      ))}
    </>
  )
}

const App = () => (
  <ErrorBoundary fallback={<div>Failed to load todos</div>}>
    <Suspense fallback={<div>Loading...</div>}>
      <TodoList />
    </Suspense>
  </ErrorBoundary>
)

See the React Suspense section in Live Queries for more details.

For sync adapter authors

A sync adapter reports failures with three calls:

CallMeaningEffect
reportError(error)Something went wrong; I am still trying.Sets failureReason and adds the failure to its errors. An error value that is already listed changes nothing. Only 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. Only after the first ready.
reportHealthy(error)This part works again.Removes the failure whose cause is error from errors. failureReason clears when no failure is left. Only after the first ready.
ts
sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => {
  let ready = false
  const stream = connect({
    onData: (changes) => {
      // The run's first batch is all of its rows: a snapshot replaces
      // the rows that a reload keeps. Each later batch carries the
      // stream's own change types, so a held row gets an update.
      begin(ready ? undefined : { snapshot: true })
      changes.forEach(({ type, value }) => write({ type, value }))
      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, or after fail. markReady after the run is ready, or after its fail, does nothing. Calls after a crash do nothing. reportHealthy when nothing is wrong changes nothing, so you can call it after every commit.
  • When several parts of your source can fail at the same time, such as separate requests, report each part's own error and call reportHealthy(error) with that error when the part recovers. The other failures stay listed. reportHealthy(error) for an error that is not listed changes nothing.
  • An error from fail clears only through reload(), which calls your sync function again for a new run.
  • Keep the run's readiness in your sync function, as ready above does. Do not read collection.status to learn whether this run is ready: during a reload it still shows the previous run's status.
  • During a reload, your sync function runs again while the Collection still holds the previous run's rows. Send the new run's full state as one snapshot (begin({ snapshot: true })), or write against the kept rows: update a row the Collection holds, delete a row that is gone, then call markReady(). Your adapter has four obligations during a reload (#2269):
    • Never insert a key the Collection holds: write update for it. An insert of a held key with a different value throws DuplicateKeySyncError, during a reload as at any time, also for a replayed loadSubset that lands after the reload completed.
    • Remove the rows the backend deleted yourself. Core does not delete kept rows after a reload. An on-demand Collection's loadSubset result is partial, so never mark it as a snapshot; delete a row from your stream, or through the row ownership your adapter keeps.
    • Honor the loadSubset signal. When it aborts, the load is over for core: stop the fetch and write no rows for it. Core ignores a later resolve or reject of that load.
    • A loadSubset that throws synchronously fails the load exactly as one that returns a rejected promise.

Migrating

BeforeAfter
isErrorisLoadingError (first load failed) or isCrashed (storage broke)
status: 'error' after a crashstatus: 'crashed'
markError(error)fail(error)
reportError(error, { final })reportError(error) or fail(error)
node phase, final, isErrornode failureReason or error
node instancesremoved
cleanup() then preload() to recoverreload()
begin({ direct: true })begin(): every write applies on the kept rows
a reload's first commit replaced every rowbegin({ snapshot: true }) or truncate() replaces them
markReady() after fail() threwit does nothing
cleanup() with live queries mountedrejects; unmount first or cleanup({ dependents: true })
persistedStatus, isPersistedReady, persistedError, preloadForInitialRender()status and health
query-db utils.lastError, isError, errorCount, clearError()health, reload()

Transaction Error Handling

When mutations fail, TanStack DB automatically rolls back optimistic updates:

ts
const todoCollection = createCollection({
  id: "todos",
  onInsert: async ({ transaction }) => {
    const response = await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify(transaction.mutations[0].modified),
    })
    
    if (!response.ok) {
      // Throwing an error will rollback the optimistic state
      throw new Error(`HTTP Error: ${response.status}`)
    }
    
    return response.json()
  },
})

// Usage - optimistic update will be rolled back if the mutation fails
try {
  const tx = todoCollection.insert({
    id: "1",
    text: "New todo",
    completed: false,
  })
  
  await tx.when('settled')
} catch (error) {
  // The optimistic update has been automatically rolled back
  console.error("Failed to create todo:", error)
}

Transaction States and Error Information

Transactions have the following states:

  • pending - Transaction is being processed
  • persisting - Currently executing the mutation function
  • completed - Transaction completed successfully
  • failed - Transaction failed and was rolled back

Access transaction error information from collection operations:

ts
const todoCollection = createCollection({
  id: "todos",
  onUpdate: async ({ transaction }) => {
    const response = await fetch(`/api/todos/${transaction.mutations[0].key}`, {
      method: "PUT",
      body: JSON.stringify(transaction.mutations[0].modified),
    })
    
    if (!response.ok) {
      throw new Error(`Update failed: ${response.status}`)
    }
  },
})

try {
  const tx = await todoCollection.update("todo-1", (draft) => {
    draft.completed = true
  })
  
  await tx.when('settled')
} catch (error) {
  // Transaction has been rolled back
  console.log(tx.state) // "failed"
  console.log(tx.error) // { message: "Update failed: 500", error: Error }
}

Or with manual transaction creation:

ts
const tx = createTransaction({
  mutationFn: async ({ transaction }) => {
    throw new Error("API failed")
  }
})

tx.mutate(() => {
  collection.insert({ id: "1", text: "Item" })
})

try {
  await tx.commit()
} catch (error) {
  // Transaction has been rolled back
  console.log(tx.state) // "failed"
  console.log(tx.error) // { message: "API failed", error: Error }
}

Explicit cancellation is different from a mutation failure. If you call tx.rollback() while mutationFn is pending, the rollback settles tx.when('settled') as rejected. A later result or rejection from that mutation function is ignored: the outstanding commit() call resolves and tx.error is not populated by that late rejection. Observe tx.when('settled') when you need the transaction's outcome, including explicit cancellation.

After the mutation function succeeds, a publication listener can still throw while the completed transaction updates its collections. In that case commit() rejects with the listener error, but tx.when('settled') resolves and the transaction remains completed. This is not a persistence failure.

Collection Operation Errors

Invalid Collection State

A Collection whose first load failed (status error) or that crashed (status crashed) rejects writes with CollectionInErrorStateError. Recover by status:

  • error: call reload(). It starts a new sync run, and live queries over the Collection follow it. See Reloading.
  • crashed: a crash cannot be recovered in place, and reload() rejects. Clean up the Collection together with everything that reads from it, then start it again. Plain cleanup() rejects with CollectionHasSubscribersError while a live query or an Effect reads the Collection. See When a Collection crashes.
ts
import { CollectionInErrorStateError } from "@tanstack/db"

try {
  todoCollection.insert(newTodo)
} catch (error) {
  if (error instanceof CollectionInErrorStateError) {
    if (todoCollection.status === "crashed") {
      // Ends its live queries and Effects first; mount them again afterward.
      await todoCollection.cleanup({ dependents: true })
      await todoCollection.preload() // a fresh start
    } else {
      await todoCollection.reload() // the first load failed: try again
    }

    // Now retry the operation
    todoCollection.insert(newTodo)
  }
}

Missing Mutation Handlers

Direct mutations require handlers to be configured:

ts
const todoCollection = createCollection({
  id: "todos",
  getKey: (todo) => todo.id,
  // Missing onInsert handler
})

// This will throw an error
todoCollection.insert(newTodo)
// Error: Collection.insert called directly (not within an explicit transaction) but no 'onInsert' handler is configured

Insert Operation Errors

DuplicateKeyError

Thrown when inserting items with existing keys:

ts
import { DuplicateKeyError } from "@tanstack/db"

try {
  todoCollection.insert({ id: "existing-id", text: "Todo" })
} catch (error) {
  if (error instanceof DuplicateKeyError) {
    console.log(`Duplicate key: ${error.message}`)
    // Consider using update() instead, or check if item exists first
  }
}

UndefinedKeyError

Thrown when an object is created without a defined key:

ts
import { UndefinedKeyError } from "@tanstack/db"

const collection = createCollection({
  id: "todos",
  getKey: (item) => item.id,
})

try {
  collection.insert({ text: "Todo" }) // Missing 'id' field
} catch (error) {
  if (error instanceof UndefinedKeyError) {
    console.log("Item is missing required key field")
    // Ensure your items have the key field defined by getKey
  }
}

Update Operation Errors

UpdateKeyNotFoundError

Thrown when trying to update a key that doesn't exist in the collection:

ts
import { UpdateKeyNotFoundError } from "@tanstack/db"

try {
  todoCollection.update("nonexistent-key", draft => {
    draft.completed = true
  })
} catch (error) {
  if (error instanceof UpdateKeyNotFoundError) {
    console.log("Key not found - item may have been deleted")
    // Consider using insert() if the item doesn't exist
  }
}

KeyUpdateNotAllowedError

Thrown when attempting to change an item's key (not allowed - delete and re-insert instead):

ts
import { KeyUpdateNotAllowedError } from "@tanstack/db"

try {
  todoCollection.update("todo-1", draft => {
    draft.id = "todo-2" // Not allowed!
  })
} catch (error) {
  if (error instanceof KeyUpdateNotAllowedError) {
    console.log("Cannot change item keys")
    // Instead, delete the old item and insert a new one
  }
}

Delete Operation Errors

DeleteKeyNotFoundError

Thrown when trying to delete a key that doesn't exist:

ts
import { DeleteKeyNotFoundError } from "@tanstack/db"

try {
  todoCollection.delete("nonexistent-key")
} catch (error) {
  if (error instanceof DeleteKeyNotFoundError) {
    console.log("Key not found - item may have already been deleted")
    // This may be acceptable in some scenarios (idempotent deletes)
  }
}

Sync Error Handling

Query Collection Sync Errors

Query collections distinguish an initial load failure from a later refetch failure:

ts
import { queryCollectionOptions } from "@tanstack/query-db-collection"

const todoCollection = createCollection(
  queryCollectionOptions({
    queryKey: ["todos"],
    queryFn: async () => {
      const response = await fetch("/api/todos")
      if (!response.ok) {
        throw new Error(`Failed to fetch: ${response.status}`)
      }
      return response.json()
    },
    queryClient,
    getKey: (item) => item.id,
    schema: todoSchema,
    // Standard TanStack Query error handling options
    retry: 3,
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  })
)

When sync errors occur:

  • Error is logged to console: [QueryCollection] Error observing query...
  • A failure before the first successful result fails the first load: the status is error, and readiness waits such as preload() and toArrayWhenReady() reject with the cause
  • A later refetch failure keeps the Collection ready with its cached data and sets the source node's failureReason, because TanStack Query fetches again
  • A later successful refetch clears the failureReason
  • To retry a failed first load, call collection.reload()

Sync Write Errors

Sync functions must handle their own errors during write operations:

ts
const collection = createCollection({
  id: "todos",
  sync: {
    sync: ({ begin, write, commit }) => {
      begin()
      
      try {
        // Will throw if key already exists
        write({ type: "insert", value: { id: "existing-id", text: "Todo" } })
      } catch (error) {
        // Error: Cannot insert document with key "existing-id" from sync because it already exists
      }
      
      commit()
    }
  }
})

Cleanup Error Handling

Cleanup errors are isolated to prevent blocking the cleanup process:

ts
const collection = createCollection({
  id: "todos",
  sync: {
    sync: ({ begin, commit }) => {
      begin()
      commit()
      
      // Return a cleanup function
      return () => {
        // If this throws, the error is re-thrown in a microtask
        // but cleanup continues successfully
        throw new Error("Sync cleanup failed")
      }
    },
  },
})

// Cleanup completes even if the sync cleanup function throws
await collection.cleanup() // Resolves successfully
// Error is re-thrown asynchronously via queueMicrotask

Error Recovery Patterns

Reload or Recreate

To retry a failed first load or a stopped sync, call reload() on the Collection or on the live query that reads it. See Reloading. A crashed Collection cannot reload; clean it up with { dependents: true } and start it again. See When a Collection crashes.

Graceful Degradation

After the first ready, Collections keep working with their rows when sync fails. The health tree tells you which source has the problem and whether its sync adapter is still trying:

tsx
import { findProblems } from "@tanstack/db"
import { useLiveQuery } from "@tanstack/react-db"

const TodoApp = () => {
  const { data, health, collection } = useLiveQuery((query) =>
    query.from({ todos: todoCollection })
  )
  const [problem] = health ? findProblems(health) : []

  return (
    <div>
      {problem?.error && (
        <div>
          Sync stopped.{" "}
          <button onClick={() => collection.reload()}>Reload</button>
        </div>
      )}
      {problem?.failureReason && <div>Reconnecting. Showing saved data.</div>}
      {data.map((todo) => <TodoItem key={todo.id} todo={todo} />)}
    </div>
  )
}

Transaction Rollback Cascading

When a transaction fails, conflicting transactions are automatically rolled back:

ts
const tx1 = createTransaction({ mutationFn: async () => {} })
const tx2 = createTransaction({ mutationFn: async () => {} })

tx1.mutate(() => collection.update("1", draft => { draft.value = "A" }))
tx2.mutate(() => collection.update("1", draft => { draft.value = "B" })) // Same item

// Rolling back tx1 will also rollback tx2 due to conflict
tx1.rollback() // tx2 is automatically rolled back

Transaction Lifecycle Errors

Transactions validate their state before operations to prevent misuse. Here are the specific errors you may encounter:

MissingMutationFunctionError

Thrown when creating a transaction without a required mutationFn:

ts
import { MissingMutationFunctionError } from "@tanstack/db"

try {
  const tx = createTransaction({}) // Missing mutationFn
} catch (error) {
  if (error instanceof MissingMutationFunctionError) {
    console.log("mutationFn is required when creating a transaction")
  }
}

TransactionNotPendingMutateError

Thrown when calling mutate() after a transaction is no longer pending:

ts
import { TransactionNotPendingMutateError } from "@tanstack/db"

const tx = createTransaction({ mutationFn: async () => {} })

await tx.commit()

try {
  tx.mutate(() => {
    collection.insert({ id: "1", text: "Item" })
  })
} catch (error) {
  if (error instanceof TransactionNotPendingMutateError) {
    console.log("Cannot mutate - transaction is no longer pending")
  }
}

TransactionNotPendingCommitError

Thrown when calling commit() after a transaction is no longer pending:

ts
import { TransactionNotPendingCommitError } from "@tanstack/db"

const tx = createTransaction({ mutationFn: async () => {} })
tx.mutate(() => collection.insert({ id: "1", text: "Item" }))

await tx.commit()

try {
  await tx.commit() // Trying to commit again
} catch (error) {
  if (error instanceof TransactionNotPendingCommitError) {
    console.log("Transaction already committed")
  }
}

TransactionAlreadyCompletedRollbackError

Thrown when calling rollback() on a transaction that's already completed:

ts
import { TransactionAlreadyCompletedRollbackError } from "@tanstack/db"

const tx = createTransaction({ mutationFn: async () => {} })
tx.mutate(() => collection.insert({ id: "1", text: "Item" }))

await tx.commit()

try {
  tx.rollback() // Can't rollback after commit
} catch (error) {
  if (error instanceof TransactionAlreadyCompletedRollbackError) {
    console.log("Cannot rollback - transaction already completed")
  }
}

Sync Transaction Errors

When working with sync transactions, these errors can occur:

NoPendingSyncTransactionWriteError

Thrown when calling write() without an active sync transaction:

ts
const collection = createCollection({
  id: "todos",
  sync: {
    sync: ({ write }) => {
      // Calling write without begin() first
      write({ type: "insert", value: { id: "1", text: "Todo" } })
      // Error: No pending sync transaction to write to
    }
  }
})

SyncTransactionAlreadyCommittedWriteError

Thrown when calling write() after the sync transaction is already committed:

ts
const collection = createCollection({
  id: "todos",
  sync: {
    sync: ({ begin, write, commit }) => {
      begin()
      commit()

      // Trying to write after commit
      write({ type: "insert", value: { id: "1", text: "Todo" } })
      // Error: The pending sync transaction is already committed
    }
  }
})

NoPendingSyncTransactionCommitError

Thrown when calling commit() without an active sync transaction.

SyncTransactionAlreadyCommittedError

Thrown when calling commit() on a sync transaction that's already committed.

Best Practices

  1. Use instanceof checks - Use instanceof instead of string matching for error handling:

    ts
    // ✅ Good - type-safe error handling
    if (error instanceof SchemaValidationError) {
      // Handle validation error
    }
    
    // ❌ Avoid - brittle string matching  
    if (error.message.includes("validation failed")) {
      // Handle validation error
    }
  2. Import specific error types - Import only the error classes you need for better tree-shaking

  3. Always handle SchemaValidationError - Provide clear feedback for validation failures

  4. Check both questions - Use status (isLoading, isLoadingError, isCrashed) for whether data can be shown, and isSyncError or health for whether it may be stale

  5. Handle transaction promises - Always handle when('settled') rejections

Example: Complete Error Handling

tsx
import {
  createCollection,
  createLiveQueryCollection,
  SchemaValidationError,
  DuplicateKeyError,
  UpdateKeyNotFoundError,
  DeleteKeyNotFoundError,
  TransactionNotPendingCommitError,
  createTransaction
} from "@tanstack/db"
import { useLiveQuery } from "@tanstack/react-db"

const todoCollection = createCollection({
  id: "todos",
  schema: todoSchema,
  getKey: (todo) => todo.id,
  onInsert: async ({ transaction }) => {
    const response = await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify(transaction.mutations[0].modified),
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }

    return response.json()
  },
  sync: {
    sync: ({ begin, write, commit, markReady }) => {
      // Your sync implementation
      begin()
      // ... sync logic
      commit()
      markReady()
    }
  }
})

const todosQuery = createLiveQueryCollection((query) =>
  query.from({ todos: todoCollection })
)

const TodoApp = () => {
  const { data, isLoadingError, isCrashed, isLoading, isSyncError } =
    useLiveQuery(todosQuery)

  const handleAddTodo = async (text: string) => {
    try {
      const tx = await todoCollection.insert({
        id: crypto.randomUUID(),
        text,
        completed: false,
      })
      
      // Wait for persistence
      await tx.when('settled')
    } catch (error) {
      if (error instanceof SchemaValidationError) {
        alert(`Validation error: ${error.issues[0]?.message}`)
      } else if (error instanceof DuplicateKeyError) {
        alert("A todo with this ID already exists")
      } else {
        alert(`Failed to add todo: ${String(error)}`)
      }
    }
  }

  const handleReload = async () => {
    try {
      await todosQuery.reload()
    } catch (error) {
      alert(`Failed to reload todos: ${String(error)}`)
    }
  }

  if (isCrashed) {
    return <div>Local storage failed. Reload the page.</div>
  }

  if (isLoadingError) {
    return (
      <div>
        <div>Todos could not load</div>
        <button onClick={handleReload}>Try again</button>
      </div>
    )
  }

  if (isLoading) {
    return <div>Loading todos...</div>
  }

  return (
    <div>
      {isSyncError && <div>Todos may be out of date</div>}
      <button onClick={() => handleAddTodo("New todo")}>
        Add Todo
      </button>
      {data.map((todo) => (
        <div key={todo.id}>{todo.text}</div>
      ))}
    </div>
  )
}

See Also