skip to content

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?

level: seniorimportance: should knowfreq 32%

answer

  1. restore is asynchronous
  2. gcTime shorter than maxAge
  3. maxAge defaults to 24 hours
  4. buster mismatch discards everything
  5. only successful queries are dehydrated

basics

~20 s

Usually 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 s

The 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 lines
tsx
import 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

for a junior

Recall the two pieces, an async storage persister and PersistQueryClientProvider, and that restoring from disk is asynchronous.

for a middle

Explain why gcTime must be at least maxAge, and what maxAge and buster do to a stored cache on restore.

for a senior

Diagnose an empty cold start systematically: store present or discarded, query saved or skipped for its status, restore raced or awaited, and throttled writes.

for a principal

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