A React Native investing app persists its TanStack Query v5 cache to AsyncStorage, yet the portfolio is empty after a cold start; what are the usual causes?
answer
- restore is asynchronous
- gcTime shorter than maxAge
- maxAge defaults to 24 hours
- buster mismatch discards everything
- only successful queries are dehydrated
basics
~20 sUsually one of four causes: gcTime shorter than maxAge dropped entries before the save, the store was discarded for age or a buster mismatch, the last refetch errored so the query was not saved, or queries ran before the async restore.
solid answer
~50 sThe setup is `createAsyncStoragePersister({ storage: AsyncStorage })` plus `PersistQueryClientProvider` in place of `QueryClientProvider`. Restores are asynchronous; the provider holds queries at `fetchStatus: 'idle'` until the restore resolves, whereas calling `persistQueryClient` beside a plain provider races the first fetch. The persisted store is written from the live cache, so entries removed by `gcTime` (five minutes by default) are gone from the next write; set `gcTime` at least as long as `maxAge`. On restore, the whole store is discarded if it is older than `maxAge` (24 hours by default) or its `buster` differs from the current one, which is what a build-hash buster does after every release. Only queries in `status: 'success'` are dehydrated, so a portfolio whose last refetch errored is not saved at all. Writes are throttled to one per second, so a kill right after a change can lose it.
code
tsx · 24 linesimport AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import type { ReactNode } from 'react';
const DAY = 1000 * 60 * 60 * 24;
const queryClient = new QueryClient({
defaultOptions: { queries: { gcTime: DAY } }, // at least maxAge
});
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
export function QueryRoot({ children, buildId }: { children: ReactNode; buildId: string }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: DAY, buster: buildId }}
>
{children}
</PersistQueryClientProvider>
);
}go deeper
Recall the two pieces, an async storage persister and PersistQueryClientProvider, and that restoring from disk is asynchronous.
Explain why gcTime must be at least maxAge, and what maxAge and buster do to a stored cache on restore.
Diagnose an empty cold start systematically: store present or discarded, query saved or skipped for its status, restore raced or awaited, and throttled writes.
Decide what may be written to disk at all, and how cache busting is tied to releases so stale shapes never meet new code.
## How persistence works in TanStack Query v5 Persisting means **dehydrating** the query cache into a plain object, writing it to storage, and **hydrating** it back into a `QueryClient` on the next launch. In a React Native app the usual pieces are: - `createAsyncStoragePersister` from `@tanstack/query-async-storage-persister`, given any `storage` object with `getItem`, `setItem` and `removeItem` (sync or async), typically `@react-native-async-storage/async-storage`; - `PersistQueryClientProvider` from `@tanstack/react-query-persist-client`, used **instead of** `QueryClientProvider`, with `persistOptions={{ persister }}`. The synchronous persister (`createSyncStoragePersister`) is deprecated; the async one accepts synchronous storages too. The persister saves the whole client as one entry, under the key `REACT_QUERY_OFFLINE_CACHE` by default, with a `timestamp` and a `buster` string next to the dehydrated state. ## The four usual causes of an empty portfolio **1. Garbage collection ran before the save.** The persisted store is rewritten from the live cache. A query with no observers is removed after `gcTime`, five minutes by default, and the next write no longer contains it. The docs therefore say to set `gcTime` to the same value as `maxAge` or higher, for example 24 hours, in the `QueryClient`'s `defaultOptions`. **2. The whole store was discarded on restore.** `persistQueryClientRestore` removes the stored client, without an error, if: | Condition | Default | Typical trigger | |---|---|---| | older than `maxAge` | 24 hours | user opens the app after a weekend | | `buster` differs | `''` | buster set to the app version or build hash, and a new release shipped | | no timestamp or restore throws | n/a | corrupted or hand-edited entry | A build-hash buster is a deliberate choice: it prevents an old response shape from reaching new code, at the cost of an empty first screen after each update. **3. The query was never saved.** The default dehydration predicate keeps only queries whose `status` is `'success'`. If the last portfolio refetch failed, the query's status is `'error'`, even though it still has older data, and it is left out of the store. **4. Queries fetched before the restore finished.** Restoring is asynchronous. `PersistQueryClientProvider` tracks this and keeps queries at `fetchStatus: 'idle'` until the restore resolves, then lets them refetch unless the restored data is fresh. Calling `persistQueryClient` next to a plain `QueryClientProvider` has no such guard, so the first fetch and the restore race each other. ## Less common, still real - **Throttled writes.** The async persister writes at most once per `throttleTime`, 1000 ms by default. A change followed by an immediate process kill can miss the last write. - **JSON round-trip.** The default serializer is `JSON.stringify`, so a `Date` in the response comes back as a string; the screen may then treat restored data as malformed. - **A different key.** Two persisters, or a changed `key`, write and read different entries. ## Diagnosing it, in order 1. Read the stored entry directly from AsyncStorage at startup and check `timestamp`, `buster` and whether the portfolio key is in `clientState`. 2. If the entry exists but is discarded, compare `buster` and age against `maxAge`. 3. If the portfolio is missing from the entry, check its status at the time of the last write and whether `gcTime` removed it. 4. If the entry is fine, check that the app uses `PersistQueryClientProvider` rather than a manual restore. ## What to keep out of the store Everything in the store sits unencrypted in app storage. For an investing app, decide which queries may be written: `dehydrateOptions.shouldDehydrateQuery` receives each query and can exclude account numbers or anything you would not leave on disk. Paused mutations can be persisted the same way; how they are replayed is a separate topic from restoring reads.
- Why set a buster at all if it empties the cache after every release?The persisted store holds response shapes from the build that wrote it. After a release that changed a response type, restoring it would feed old shapes to new code. A buster tied to the build or a schema version discards the store once, on the first launch of the new build, trading one cold first screen for never rendering data the code no longer understands.
- How do you keep one sensitive query out of the persisted cache while persisting the rest?Pass `dehydrateOptions` in `persistOptions` with a `shouldDehydrateQuery` that returns `false` for that query key and falls back to the default rule, `query.state.status === 'success'`, for the rest. The query still works in memory; it is simply never written to storage, so it refetches after a cold start.
saying these in an interview costs you the question
- gcTime only affects memory, so it cannot remove anything from the persisted store
- An expired or busted store raises an error you can catch in the UI
- The last successful data is saved even if the latest refetch failed
- A plain QueryClientProvider with persistQueryClient waits for the restore
- The persister saves each change the instant it happens