Steven's Knowledge

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 reload

A 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 returns If-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 response

Use 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:

DatastaleTime
Account profile5min — rarely changes
Product list30s — changes occasionally
Order status5s + refetch on focus — high freshness
Currency rates60s — known refresh cadence
Notifications0 + 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 valuesetQueryData. Instant, no network.
  • Mutation only knows what changedinvalidateQueries. Refetches authoritative state.
  • Listed item now belongs elsewhere (status change, filter) → both: update detail cache, invalidate list.
  • DeletedremoveQueries.

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 queries

Stale-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 unliked

Fix 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-image adds a configurable memory+disk cache.
  • Flutter: cached_network_image or flutter_image_compress + custom CacheManager.

CDN-set Cache-Control is the primary lever; client cache settings are fallback.

Common Pitfalls

SymptomCauseFix
Cache "doesn't work" — same fetch every navigationstaleTime: 0 (default)Set a meaningful staleTime
Stale data foreverMutation didn't invalidateinvalidateQueries in onSuccess
Two screens fetch same data twiceDifferent query keysUse a key factory
Optimistic UI flickersNo cancelQueries before setQueryDataAlways cancel first
Persisted cache shows last user's dataNot cleared on sign-outqueryClient.clear() on sign-out
setQueryData replaces with undefinedUpdater returned undefined when previous was undefinedHandle undefined explicitly
List item updated but list doesn't reflect itUpdated detail cache onlyInvalidate the list too
Mutations replay infinitely after backend bugNo max attempts on offline queueCap with retry: 3 + DLQ for the rest

On this page