Caching & Optimistic Updates
HTTP cache, query cache, optimistic mutations, invalidation strategies, offline-first
Caching & Optimistic Updates
Caching is the layer where most "this app feels slow" problems are actually solved. The cost is correctness — stale data, races, invalidation. This page covers the four caches a frontend has and the patterns that keep them honest.
Four Caches You Already Have
[ CDN / edge cache ] ← HTTP, controlled by server
↓
[ Browser / OS HTTP cache ] ← Cache-Control / ETag
↓
[ App in-memory query cache ] ← TanStack Query / Apollo / SWR
↓
[ Persisted disk cache ] ← MMKV / IndexedDB / Hive — survives reloadA request can be served by any of them. They must agree on how long a value is fresh and how invalidation propagates upward when a mutation occurs.
HTTP Caching
The server sets headers; the runtime obeys.
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "v2-9e5"max-age=60— fresh for 60s, no revalidation.stale-while-revalidate=300— for 5 more minutes, return stale immediately, refresh in background.ETag— server sends; client returnsIf-None-Match; server may answer 304.
Frontend rarely sets these explicitly with fetch, but they affect what fetch returns and how mobile native HTTP clients (NSURLSession, OkHttp) behave.
When To Force Bypass
fetch(url, { cache: 'no-store' }); // never cache
fetch(url, { cache: 'reload' }); // always go to network, but allow caching responseUse sparingly — the right default is to trust the server's headers.
TanStack Query: The Reference Pattern
useQuery({
queryKey: ['order', id],
queryFn: ({ signal }) => api.getOrder(id, signal),
staleTime: 30_000, // serve from cache without refetch for 30s
gcTime: 5 * 60_000, // unused entries evicted after 5min
});Two Knobs
staleTime— how long data is considered fresh. While fresh, no refetch. Default: 0 — refetch on every mount unless you set it.gcTime— how long unused data stays in memory. Default 5min.
Choose staleTime per data type:
| Data | staleTime |
|---|---|
| Account profile | 5min — rarely changes |
| Product list | 30s — changes occasionally |
| Order status | 5s + refetch on focus — high freshness |
| Currency rates | 60s — known refresh cadence |
| Notifications | 0 + websocket push |
Invalidation Strategies
const qc = useQueryClient();
// 1. Refetch matching queries
qc.invalidateQueries({ queryKey: ['orders'] });
// 2. Set the cache directly (no refetch)
qc.setQueryData(['order', id], updatedOrder);
// 3. Remove from cache
qc.removeQueries({ queryKey: ['order', id] });Which To Pick
- Mutation knows the new value →
setQueryData. Instant, no network. - Mutation only knows what changed →
invalidateQueries. Refetches authoritative state. - Listed item now belongs elsewhere (status change, filter) → both: update detail cache, invalidate list.
- Deleted →
removeQueries.
Tag-Style Invalidation (Apollo / RTK Query)
Apollo's normalized cache invalidates by ID:
cache.evict({ id: cache.identify({ __typename: 'Order', id }) });
cache.gc();RTK Query uses providesTags / invalidatesTags. Both are equivalent — the mental model is "tell me what's affected and the cache figures out who to refresh".
Optimistic Updates
Apply the change locally before the server confirms. If the server rejects, roll back.
TanStack Query Pattern
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: api.toggleFavorite,
onMutate: async ({ id }) => {
// 1. Cancel in-flight refetches for this key
await queryClient.cancelQueries({ queryKey: ['product', id] });
// 2. Snapshot the previous value
const previous = queryClient.getQueryData<Product>(['product', id]);
// 3. Optimistically update
queryClient.setQueryData<Product>(['product', id], (p) =>
p ? { ...p, favorite: !p.favorite } : p,
);
return { previous };
},
onError: (_err, { id }, ctx) => {
// 4. Roll back
if (ctx?.previous) queryClient.setQueryData(['product', id], ctx.previous);
},
onSettled: (_data, _err, { id }) => {
// 5. Re-sync with server truth
queryClient.invalidateQueries({ queryKey: ['product', id] });
},
});When NOT To Be Optimistic
- The user must know if the action succeeded before they continue (e.g., payments).
- The server can return a different shape than the optimistic guess (e.g., server-assigned IDs).
- The action is rare and slow — confirmation is fine.
For payments: show a spinner, wait for confirmation. Optimism is for high-frequency, low-risk actions (likes, todos, drag-reorder).
Offline-First Patterns
Persist The Cache
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
const persister = createSyncStoragePersister({
storage: {
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
},
});
persistQueryClient({ queryClient, persister, maxAge: 24 * 60 * 60 * 1000 });On next launch, queries hydrate from disk → instant UI → revalidate in background.
Mutation Queue
import { onlineManager } from '@tanstack/react-query';
useMutation({
mutationFn: api.updateOrder,
// Optional: replay paused mutations when back online
networkMode: 'offlineFirst',
});
// React to network changes
import NetInfo from '@react-native-community/netinfo';
NetInfo.addEventListener((state) => {
onlineManager.setOnline(!!state.isConnected);
});When offline, mutations queue. When online, they replay in order.
For richer offline (rewriting reads against pending mutations), look at libraries like react-query-mutation-cache or build a per-feature outbox.
Cache Keys: Discipline Wins
Queries with the same logical data must share a key. Otherwise you fetch twice, render twice, invalidate inconsistently.
// Bad: two screens, two keys, two fetches
useQuery({ queryKey: ['orders', filters], queryFn: () => api.getOrders(filters) });
useQuery({ queryKey: ['orderList'], queryFn: () => api.getOrders(filters) });
// Good: factory ensures consistency
export const orderKeys = {
all: ['orders'] as const,
list: (f: OrderFilters) => [...orderKeys.all, 'list', f] as const,
detail: (id: string) => [...orderKeys.all, 'detail', id] as const,
};
useQuery({ queryKey: orderKeys.list(filters), queryFn: () => api.getOrders(filters) });Stable key factories also make invalidation reliable:
queryClient.invalidateQueries({ queryKey: orderKeys.all }); // invalidate ALL order queriesStale-While-Revalidate UX
The cleanest UX combines:
- Instant render from cache.
- Subtle indicator that we're refreshing.
- Smooth replacement when fresh data arrives.
const { data, isFetching } = useOrders();
return (
<>
{isFetching && <SmallSpinner />}
{data && <OrderList orders={data} />}
</>
);isFetching is true while a request is in flight (including background). isLoading is true only when there's no data yet.
Race Conditions
Last-Write-Wins
Two concurrent mutations for the same field — the later response wins, even if it raced ahead of an earlier one.
// User clicks like → unlike rapidly
// Mutation A (like) — fast network this time
// Mutation B (unlike) — slow
// B's onSuccess runs after A's, overwrites → final state: liked, but UI shows unlikedFix with mutationKey + cancellation:
useMutation({
mutationKey: ['product', id, 'favorite'],
mutationFn: api.toggleFavorite,
});Or track a request sequence number and ignore stale responses.
Pagination Page Drift
If a list refetches while the user has scrolled to page 5, naive cache replacement loses pages 2–5. Use useInfiniteQuery which appends per cursor; invalidation refetches all loaded pages atomically.
Image Cache
<img> and platform image components have their own cache controlled by Cache-Control. For mobile:
- React Native:
expo-imageadds a configurable memory+disk cache. - Flutter:
cached_network_imageorflutter_image_compress+ custom CacheManager.
CDN-set Cache-Control is the primary lever; client cache settings are fallback.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Cache "doesn't work" — same fetch every navigation | staleTime: 0 (default) | Set a meaningful staleTime |
| Stale data forever | Mutation didn't invalidate | invalidateQueries in onSuccess |
| Two screens fetch same data twice | Different query keys | Use a key factory |
| Optimistic UI flickers | No cancelQueries before setQueryData | Always cancel first |
| Persisted cache shows last user's data | Not cleared on sign-out | queryClient.clear() on sign-out |
setQueryData replaces with undefined | Updater returned undefined when previous was undefined | Handle undefined explicitly |
| List item updated but list doesn't reflect it | Updated detail cache only | Invalidate the list too |
| Mutations replay infinitely after backend bug | No max attempts on offline queue | Cap with retry: 3 + DLQ for the rest |