Skip to content

persistQueryClientSubscribe drops the persistQueryClientSave promise, causing unhandled rejections when persisting fails #11663

Description

@n-satoshi061

persistQueryClientSubscribe calls persistQueryClientSave(props) on every cache event, but it doesn't handle the returned promise. So if saving fails, I get an unhandled promise rejection on each cache update.

I noticed this while reading the code and confirmed it with a test. It can happen with a custom persister like the IndexedDB example in the docs, because set() from idb-keyval can reject, for example with QuotaExceededError or DataCloneError. It can also happen with the built-in persisters if a dehydrateOptions callback like shouldDehydrateQuery throws.

Small repro:

const queryClient = new QueryClient()

persistQueryClientSubscribe({
  queryClient,
  persister: {
    persistClient: () => Promise.reject(new Error('quota exceeded')),
    restoreClient: () => undefined,
    removeClient: () => undefined,
  },
})

queryClient.setQueryData(['a'], 1) // -> unhandled rejection

This affects all the persist providers, since they all use persistQueryClientSubscribe. That includes Vue's clientPersister when it's used with persistQueryClient().

The restore side already catches errors and logs a warning in dev (#8969), but the save side doesn't. I know error handling has mostly been left to the persister (#3527), so I'm not sure which way you'd prefer:

  1. catch it in persistQueryClientSubscribe and log in dev, same as restore
  2. keep the code as is, and add a try/catch to the IndexedDB example in the docs

I'm happy to send a PR for either one.

Activity

  1. amasen02 commented on Sep 27, 2026

    @amasen02

    Root Cause Analysis

    In packages/query-persist-client-core/src/persist.ts, persistQueryClientSubscribe registers synchronous cache listeners on the query and mutation caches:

    s const unsubscribeQueryCache = props.queryClient .getQueryCache() .subscribe((event) => { if (isCacheEventType(event.type)) { persistQueryClientSave(props) } })

    Because persistQueryClientSave is an �sync function returning Promise, calling it in a synchronous event subscriber without attaching a .catch() drops the returned promise on the floor.

    When persistence fails—such as:

    1. Custom or built-in persisters rejecting (e.g. idb-keyval or localStorage throwing QuotaExceededError, DataCloneError, or private browsing mode storage restrictions), or
    2. Synchronous throws inside dehydrate(queryClient, dehydrateOptions) (e.g., throwing inside shouldDehydrateQuery or a custom serializer)—

    the returned promise rejects with no error handler. In modern runtimes (Node.js >= 15 SSR crashing on unhandled rejections, and browser window.onunhandledrejection), this surfaces as an unhandled promise rejection error.


    Comparison with persistQueryClientRestore

    In PR #8969, persistQueryClientRestore gained error handling and dev logging:

    ` s
    } catch (err) {
    if (process.env.NODE_ENV !== 'production') {
    console.error(err)
    console.warn(
    'Encountered an error attempting to restore client cache from persisted location. As a precaution, the persisted cache will be discarded.',
    )
    }

    await persister.removeClient()

    throw err
    }
    `

    While persistQueryClientRestore can rethrow because it is awaited during initial mount (
    estorePromise), persistQueryClientSubscribe runs continuously throughout the application lifecycle on reactive cache events. Floating a rejected promise here is hazardous.


    Recommended Solution

    Option 1 is the cleanest and most robust approach for the library:

    1. Catch and log in persistQueryClientSubscribe:
      Catch the rejection from persistQueryClientSave(props). In development (process.env.NODE_ENV !== 'production'), log a warning to match the restore side:

    ` s
    export function persistQueryClientSubscribe(
    props: PersistedQueryClientSaveOptions,
    ) {
    const save = () => {
    persistQueryClientSave(props).catch((err) => {
    props.onPersistError?.(err)
    if (process.env.NODE_ENV !== 'production') {
    console.error(err)
    console.warn(
    'Encountered an error attempting to persist client cache to persisted location.',
    )
    }
    })
    }

    const unsubscribeQueryCache = props.queryClient
    .getQueryCache()
    .subscribe((event) => {
    if (isCacheEventType(event.type)) {
    save()
    }
    })

    const unsubscribeMutationCache = props.queryClient
    .getMutationCache()
    .subscribe((event) => {
    if (isCacheEventType(event.type)) {
    save()
    }
    })

    return () => {
    unsubscribeQueryCache()
    unsubscribeMutationCache()
    }
    }
    `

    1. Optional hook in PersistedQueryClientSaveOptions:
      Optionally exposing onPersistError?: (error: unknown) => void in PersistedQueryClientSaveOptions allows consumers to forward persistent storage errors to telemetry (e.g., Sentry) or notify users if quota limits are exceeded.

    2. Docs update:
      Updating the IndexedDB docs example with defensive error handling is also good practice, but the core subscriber must safeguard against unhandled rejections regardless of how userland persisters are written.

  2. n-satoshi061 commented on Sep 27, 2026

    @n-satoshi061
    ContributorAuthor

    A clarification on scope, after checking the built-in persisters on current main:

    createSyncStoragePersister and createAsyncStoragePersister already catch errors from storage writes internally (via trySave/retry, and asyncThrottle for the async one). So storage errors like QuotaExceededError don't reach this path when using them.

    This means the unhandled rejection happens in two cases:

    • a custom persister whose persistClient rejects (e.g. the IndexedDB example in the docs)
    • any persister, including the built-in ones, when a dehydrateOptions callback such as shouldDehydrateQuery throws
  3. mgarcialeniolabs commented on Sep 28, 2026

    @mgarcialeniolabs

    I'll work on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions