React Query is not your offline database
Offline support often starts with a tempting shortcut: persist the entire React Query cache. The application already has a cache, so why add another one?
On mobile, that shortcut can turn storage into a memory problem. Hydrating a large query cache creates many parsed JavaScript objects before the first useful screen appears. The app may also keep the same payload in a durable store, a query cache, and a prefetched result. Each copy has a cost.
The more useful model gives each layer one job. React Query is the hot, disposable cache for the current session. MMKV is the durable local snapshot. initialData is the bridge between them. The network remains the authority for fresh data. That read-side cache has a different job from the durable write path described in Offline queues that dont lie.
Two caches, two jobs
React Query handles query lifecycle. It deduplicates requests, tracks loading and error state, serves data to mounted components, and decides when stale queries should refetch. Its in-memory cache is valuable because it avoids repeated reads while a user moves through the app. It does not need to be the long-term record of everything the app has seen.
MMKV handles local persistence. The cache layer stores JSON in separate MMKV partitions, with keys scoped to the current account context. Simple catalog data can use a whole-query blob keyed by a hash of the React Query key. Larger domain records use an entity store with named shards.
That distinction matters. A catalog query can often be read and written as one unit. A large domain record may contain a list summary, editable fields, photos, and signatures. Reading the summary should not require parsing every heavy field. The storage model should follow the access pattern.
The durable store is the canonical local copy in this design. React Query receives a session copy when a screen mounts. A network response updates React Query and writes the durable store through the cache policy. React Query itself is free to disappear when its data is no longer useful.
Hydrate with initialData
The connection is small enough to fit inside query options:
const detailOptions = queryOptions({
queryKey: ["record", recordId, "full"],
queryFn: () => fetchRecord(recordId),
initialData: () => readRecordFromShards(scopeId, recordId, "full"),
initialDataUpdatedAt: 0,
});
The function reads the local snapshot synchronously when the query is created. If a shard exists, the screen can render useful data without waiting for the network. TanStack documents initialData as data that seeds the query cache, and the function form is expected to return synchronously. initialDataUpdatedAt supplies the timestamp React Query should use when deciding whether that data is fresh.
Setting it to 0 makes the policy explicit: local data is immediately stale. React Query can show it, then reconcile it in the background. A durable snapshot removes the blank screen. It does not make the snapshot current.
This detail also keeps the responsibilities visible. The cache policy knows how a fetch reads and writes persistent data. The query options know how a screen hydrates. Both must point at the same store and shape. A policy that writes shards without matching initialData leaves offline data on disk with no read path.
Keep the first read cheap
Synchronous hydration has a limit. A synchronous read is useful only while its parsing cost stays small enough for the render path. The cache layer estimates serialized size and routes generic payloads at or above 100 KB to a separate MMKV partition. Query options can skip synchronous parsing for oversized values and let the network or a later, deliberate read supply them.
The threshold is a policy boundary, not a performance law. Measure it on the devices and payloads that matter. The important idea is to keep a large JSON document from becoming an invisible startup task.
The same principle applies to query lifetime. React Query garbage-collects inactive queries after their configured window, and the application can clear the QueryClient when memory pressure arrives. The durable snapshot remains available for the next screen. The app pays the parsing cost again only when it needs the data again.
Shards make memory an API decision
Memory pressure often begins earlier, when an API asks for more fields than a screen needs. A list row does not need the full detail record. A resume screen may need the core fields. A detail screen may need photos and signatures.
The cache separates these shapes into tiers:
record:{id}:agenda list fields and status
record:{id}:core fields needed to start or resume
record:{id}:photos photo array
record:{id}:signatures signature array
The names are examples of a domain-owned suffix scheme. The useful property is the boundary: each shard represents one logical part of the record. A full read combines the available shards. A core read avoids the heavy ones. A prefetch can write the agenda and core tiers without pulling every detail field into memory.
The network shape and storage tier do not have to match one for one. A core prefetch and a full detail request can share the same core shard. The later, richer response replaces that shard and adds optional heavy shards. This avoids keeping two durable versions of the same core data.
The cache layer uses a generic policy for whole-query catalog data and an entity policy for sharded records or field-level offline work. Mixing both policies for one entity creates duplicate copies and makes eviction harder to reason about. Choose one persistence path per data family.
Bound the durable cache
Durable storage needs a budget too. MMKV provides key-value storage, but it does not provide application-level byte accounting or an LRU policy. The cache layer keeps a registry with each entry’s size, last access, category, and partition. Entity writes update the registry and trigger oldest-first eviction when the per-scope budget is exceeded.
Index entries are treated differently from ordinary data. A status index can tell the app which tiers exist and whether a record is pending, partial, or ready. Losing that metadata during routine eviction would make the rest of the cache harder to use, so index entries are protected from LRU eviction.
Scope-prefixed keys also make lifecycle operations possible. On sign-out or a scope change, the app can clear the matching durable entries and the in-memory QueryClient. The exact prefix is an implementation detail. The invariant is more important: cached data must not cross an account boundary.
Memory pressure is an explicit escape hatch
When the operating system reports memory pressure, waiting for normal query garbage collection may leave too much parsed data alive. The app clears React Query first. It then trims non-protected entity shards from durable storage while preserving the active record’s status, core data, and loaded or dirty field shards.
This is a deliberate trade. The next screen may need to hydrate again, and some older offline data may be gone. Active work has a better chance of surviving the warning. The system also records cache and memory metrics so the policy can be checked against real sessions instead of intuition.
When this pattern earns its complexity
This design fits a large React Native app with offline workflows, sizeable responses, background prefetch, and devices where memory is scarce. It gives the team clear answers to three questions: what must survive a restart, what should stay hot, and what can be discarded safely?
It adds work. The application must define cache policies, align query shapes with shard reads, reconcile stale local data, maintain indexes, and test scope changes and memory warnings. MMKV is still a key-value store. It cannot replace server queries or provide relational joins.
For a small cache, persistQueryClient may be simpler. For a mobile cache with large nested records, treating React Query as the offline database makes its lifecycle do a job it was not chosen to do. Keep React Query hot. Keep MMKV bounded and durable. Let initialData make the handoff visible.