Repository navigation
persistQueryClientSubscribe drops the persistQueryClientSave promise, causing unhandled rejections when persisting fails #11663
Description
Activity
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:
- Custom or built-in persisters rejecting (e.g. idb-keyval or localStorage throwing QuotaExceededError, DataCloneError, or private browsing mode storage restrictions), or
- 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:
- 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()
}
}
`-
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. -
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.
A clarification on scope, after checking the built-in persisters on current
main:createSyncStoragePersisterandcreateAsyncStoragePersisteralready catch errors from storage writes internally (viatrySave/retry, andasyncThrottlefor the async one). So storage errors likeQuotaExceededErrordon't reach this path when using them.This means the unhandled rejection happens in two cases:
- a custom persister whose
persistClientrejects (e.g. the IndexedDB example in the docs) - any persister, including the built-in ones, when a
dehydrateOptionscallback such asshouldDehydrateQuerythrows
- a custom persister whose
I'll work on this.
persistQueryClientSubscribecallspersistQueryClientSave(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 withQuotaExceededErrororDataCloneError. It can also happen with the built-in persisters if adehydrateOptionscallback likeshouldDehydrateQuerythrows.Small repro:
This affects all the persist providers, since they all use
persistQueryClientSubscribe. That includes Vue'sclientPersisterwhen it's used withpersistQueryClient().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:
persistQueryClientSubscribeand log in dev, same as restoretry/catchto the IndexedDB example in the docsI'm happy to send a PR for either one.