State Architecture & Cache Fundamentals

Frontend applications at SaaS scale live and die by one decision made early in the architecture: whether server-fetched data is stored in the same state layer as UI interaction state, or kept separate in a dedicated cache. This reference covers the engineering problem domain for teams building on React Query, Apollo Client, SWR, and RTK Query — from drawing client vs server state boundaries and designing your cache layer architecture, through deterministic cache normalization, synchronization workflows, and SSR hydration. The goal is a mental model and a code-level toolkit for eliminating stale UI states, preventing memory leaks, and handling race conditions before they reach production.

Architectural Overview

The diagram below traces how a network response moves through a modern cache layer: from raw fetch, through normalization into an entity store, to selective component subscriptions and background revalidation. Understanding this lifecycle makes every configuration decision — staleTime, gcTime, structural sharing, tag invalidation — concrete rather than abstract.

Cache Data-Flow Lifecycle Diagram showing data flowing from a remote API through a fetch layer, into a normalizer that produces an entity store, then fanning out to component subscriptions. A revalidation path loops from stale markers back to the fetch layer. Remote API REST / GraphQL response Fetch Layer dedup · retry · timeout queryFn / fetcher raw data Normalizer flatten · key · merge structural sharing entity dedup entities Entity Store QueryCache / Cache staleTime · gcTime Component A useQuery Component B useQuery (shared) marks stale → background revalidation (stale-while-revalidate) data path revalidation path

Core Concepts Reference

The table below maps the fundamental cache concepts to their concrete API surfaces across the four main libraries. Use it as a quick-reference before diving into the strategy sections.

Concept Definition React Query v5 Apollo Client v3 SWR v2 RTK Query
Query key Serializable identifier that uniquely addresses a cache entry queryKey: ['users', id] cache.readQuery({ query, variables }) key string / array endpoints + args
Stale time Duration a successful response is considered fresh; no network request fires while fresh staleTime (ms) fetchPolicy: 'cache-first' dedupingInterval keepUnusedDataFor
GC time How long an inactive query’s data stays in memory before eviction gcTime (ms, default 5 min) Managed by InMemoryCache retain/release Not configurable; SWR evicts on unmount keepUnusedDataFor
Structural sharing Reference equality optimization: unchanged sub-trees reuse existing object references, preventing unnecessary re-renders structuralSharing: true (default) Immutable normalized cache by default compare option Immer-based selectors
Normalization Flattening nested payloads into a keyed entity map to prevent duplicate storage Manual via setQueryData Automatic via __typename + id Manual createEntityAdapter
Optimistic update Applying a mutation result to cache before the server responds onMutate callback optimisticResponse option mutate(data, { optimisticData }) onQueryStarted with patchResult
Invalidation Marking cache entries stale so the next subscriber triggers a refetch invalidateQueries cache.evict / refetchQueries mutate() with no data argument invalidateTags
Hydration Serializing server-rendered cache state into HTML and rehydrating on the client dehydrate / HydrationBoundary getDataFromTree + InMemoryCache.extract() fallback prop on SWRConfig setupStore with preloadedState

Strategy 1: Client vs Server State Boundaries

The most consequential architecture decision for any React application is the boundary between ephemeral UI state and remote server data. Mixing them in a single store — Redux managing both a modal’s open/closed flag and a paginated user list — creates subtle bugs: server responses can clobber UI state, optimistic rollbacks can clear form input, and selectors fire unnecessarily on every network response.

Client vs server state boundaries should be drawn at the data ownership contract. A piece of state belongs to the server if the server is the system of record — meaning any device, session, or user can mutate it independently. UI state, by contrast, belongs exclusively to the client session. Common examples:

  • Server state: user profile, product catalog, notification list, permission sets, paginated feeds
  • Client state: sidebar expanded, selected tab index, multi-step form draft, pending file upload progress

Once boundaries are drawn, the library choice becomes a derivation. React Query vs Redux for server state covers this comparison in depth, including cases where RTK Query’s normalized API and Apollo’s InMemoryCache are more appropriate than the QueryCache model. If you are unsure which global state mechanism fits your current data ownership pattern, when to use global state vs query cache provides a decision tree.

Configuration trade-offs:

  • Setting staleTime: Infinity in React Query treats server responses as immutable client state — useful for reference data (countries, currencies) but dangerous for high-frequency entities where background revalidation is expected.
  • Using Redux Toolkit for server state adds reducer boilerplate and manual loading/error/success flags; RTK Query eliminates these but requires the RTK ecosystem buy-in.
  • Apollo’s cache-and-network fetch policy fires both a cache read and a network request simultaneously — lowest latency for users, highest bandwidth usage on page load.

Strategy 2: Cache Layer Architecture

The cache layer architecture governs how many caching tiers your application maintains, and how data flows between them. A common pattern in production SaaS applications is three tiers:

  1. In-memory query cache — per-session, volatile (React Query’s QueryCache, Apollo’s InMemoryCache, SWR’s internal cache map). Fastest reads; lost on page reload.
  2. Browser persistence layerlocalStorage, IndexedDB, or sessionStorage for persisting between page loads. React Query’s persistQueryClient plugin and Apollo’s InMemoryCache + persistCache utility handle this tier.
  3. Service worker cache — intercepts network requests at the browser level for offline support. Works orthogonally to the in-memory layer; must be invalidated explicitly when a mutation fires.

Moving data between these tiers requires explicit serialization contracts. Circular entity references that are valid in memory cannot be serialized to JSON without transformation. gcTime controls how long tier-1 data survives; persistence hydration controls tier-2 → tier-1 rehydration on mount.

Configuration trade-offs:

  • Enabling persistQueryClient with localStorage increases Time to Interactive because deserialization and cache hydration block the first render; use IndexedDB with async adapters for large data sets.
  • Apollo’s InMemoryCache with typePolicies for keyFields enables fine-grained normalization per type but increases bundle size and configuration surface.
  • SWR’s provider option allows injecting a Map-backed cache — useful for testing or server-side rendering, but requires manual garbage collection.

Strategy 3: Normalization & Entity Mapping

Cache normalization is the practice of transforming nested API payloads into flat entity maps keyed by a stable identifier. Without it, the same user object returned by /api/users/42 and /api/posts?author=42 lives in two separate cache entries. A mutation to the user’s email updates one entry, leaving the other stale — resulting in the UI displaying two different email addresses for the same person on the same screen.

Understanding reference vs value storage models is the prerequisite for normalization depth decisions. Reference storage means the cache holds a single canonical object and components receive references to it; value storage means each query gets its own deep copy. Reference storage prevents entity duplication but requires structural sharing to avoid re-rendering all consumers on any change to the canonical object.

For normalization principles for UI, the design question is how to assign stable keys. Apollo does this automatically using __typename + id. React Query requires you to establish the convention yourself via setQueryData with a normalized shape. Designing a flat, key-addressable shape from the start — as covered in how to design a normalized state tree — pays dividends when partial updates arrive.

When entities contain circular references in cache — for example, a comment referencing its author who references their comments — serialization and structural sharing both break. The resolution is to store only the foreign key (the author’s ID) in the entity map and look up the related entity separately.

Configuration trade-offs:

  • Apollo’s typePolicies.keyFields override lets you normalize by composite keys (e.g. ['orgId', 'userId']) for multi-tenant data — essential for correctness, adds configuration complexity.
  • React Query’s structuralSharing: true (the default) performs deep equality checks on query results; disable it (structuralSharing: false) for large blobs (binary data, Canvas state) where equality checking is more expensive than re-rendering.
  • createEntityAdapter in RTK Query pre-generates selectAll, selectById, and selectIds selectors — zero-cost normalization for CRUD-heavy list views, but requires the Redux Toolkit dependency.

Strategy 4: Synchronization & Invalidation Workflows

Keeping cache state consistent with the server after a mutation is the hardest part of client-side caching. Implementing stale-while-revalidate prevents waterfall refetches by serving stale data immediately while a background fetch runs. Background refetch strategies determines when that background fetch fires — on window focus, network reconnect, a polling interval, or an explicit imperative call.

For writes, optimistic mutation — apply the expected result before the server confirms — provides the lowest-perceived-latency UX. The trade-off is rollback complexity: onMutate must snapshot current state, onError must restore it atomically, and onSettled must reconcile with the server’s authoritative response. Mutation sync and rollback covers the mechanics of maintaining consistency through network partitions and partial failure.

When multiple entities must be invalidated atomically, tag-based invalidation systems provide a graph of relationships between cache entries and invalidation triggers — RTK Query’s providesTags/invalidatesTags API is the reference implementation.

Configuration trade-offs:

  • refetchOnWindowFocus: true (React Query default) fires a background fetch every time the tab regains focus — good for user-list feeds, disruptive for heavy analytics dashboards; disable per-query with refetchOnWindowFocus: false.
  • refetchInterval polling creates a steady network baseline regardless of user activity; consider WebSocket-based cache invalidation for lower bandwidth and lower perceived latency.
  • Apollo’s refetchQueries option in useMutation is synchronous by default — the mutation resolves only after all dependent queries complete, increasing perceived mutation latency; use awaitRefetchQueries: false for fire-and-forget invalidation.

Production Code Example: Normalized Cache Update with Optimistic Mutation

The snippet below demonstrates the full lifecycle: cancel in-flight requests, snapshot for rollback, apply a structural update to only the changed entity, roll back on failure, and revalidate on settle. Comments explain what the React Query QueryClient does internally at each step.

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface User {
  id: string;
  email: string;
  displayName: string;
}

interface NormalizedUsers {
  ids: string[];
  entities: Record<string, User>;
}

async function patchUser(updated: Partial<User> & { id: string }): Promise<User> {
  const res = await fetch(`/api/users/${updated.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updated),
  });
  if (!res.ok) throw new Error(`PATCH /api/users/${updated.id} failed: ${res.status}`);
  return res.json() as Promise<User>;
}

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation<User, Error, Partial<User> & { id: string }, { previous: NormalizedUsers | undefined }>({
    mutationFn: patchUser,

    onMutate: async (patch) => {
      // 1. Cancel any in-flight fetches for this query key so they cannot
      //    overwrite our optimistic update after it is applied.
      await queryClient.cancelQueries({ queryKey: ['users'] });

      // 2. Snapshot the current cache value for rollback. QueryClient reads
      //    from its in-memory QueryCache — O(1) lookup by serialized key.
      const previous = queryClient.getQueryData<NormalizedUsers>(['users']);

      // 3. Apply a structural update: only the targeted entity reference
      //    changes. Structural sharing means all *other* entity references
      //    remain pointer-equal, so consumers of unrelated entities skip re-render.
      queryClient.setQueryData<NormalizedUsers>(['users'], (old) => {
        if (!old) return old;
        return {
          ...old,
          entities: {
            ...old.entities,
            [patch.id]: { ...old.entities[patch.id], ...patch },
          },
        };
      });

      // Return the snapshot so onError can access it via context.
      return { previous };
    },

    onError: (_error, _patch, context) => {
      // Network or server error: restore the pre-mutation snapshot atomically.
      // QueryClient calls all active observers synchronously, triggering a
      // re-render in every subscribed component with the reverted state.
      if (context?.previous !== undefined) {
        queryClient.setQueryData(['users'], context.previous);
      }
    },

    onSettled: () => {
      // Always invalidate after settle — success or error — so the server's
      // authoritative state replaces the optimistic write. QueryClient marks
      // the entry stale and schedules a background refetch for any active
      // observer; inactive observers refetch on next mount.
      void queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Cache lifecycle walkthrough:

  • cancelQueries aborts any AbortController-linked fetches registered by active useQuery observers, preventing a race where a slow response overwrites the optimistic state.
  • getQueryData reads synchronously from QueryCache.find(queryKey).state.data — no subscription overhead.
  • setQueryData triggers the observer notification pipeline: every useQuery subscribed to ['users'] re-renders in the same React batch, using structural sharing to skip components whose data reference did not change.
  • invalidateQueries sets state.isInvalidated = true on matching entries. Active observers immediately fire a background fetch; the component continues rendering the (now stale) optimistic data until the response arrives.

Common Engineering Pitfalls

Symptom Root Cause Resolution
UI shows stale data after a successful mutation invalidateQueries not called in onSettled, or query key mismatch between mutation and query Always invalidate in onSettled (not only onSuccess) so rollback errors also trigger revalidation; log serialized query keys in development to catch key drift
Memory grows unboundedly on long-lived SPAs with many routes gcTime set to Infinity, or queries are never considered inactive because a persistent layout component keeps them mounted Set gcTime to 5–10 min; use queryClient.clear() on logout; audit query key scope to ensure route-specific queries unmount with their route
Optimistic update flickers back to stale data Server response from an in-flight useQuery arrives after onMutate and overwrites the optimistic update Call cancelQueries in onMutate before applying the optimistic patch; verify AbortSignal is forwarded in queryFn so cancellation actually aborts the fetch
React hydration mismatch error on SSR pages QueryClient populated during server render contains timestamps or UUIDs that differ between server and client serialization Use dehydrate / HydrationBoundary with a deterministic serialize option; strip updatedAt fields before dehydration; validate cache checksums on mount
Apollo InMemoryCache returns stale entity after mutation Entity was updated but the cache key (__typename + id) did not change — Apollo does not re-read the field Add the updated field to the mutation RETURNING selection set so Apollo can merge it, or call cache.modify to write the field directly

Frequently Asked Questions

When should I normalize frontend cache versus keeping nested API responses intact?

Normalize when entities appear in more than one query, require partial updates in place, or drive multiple independent UI sections. The break-even point is roughly: if you call setQueryData to update an entity in three separate cache entries after every mutation, normalization pays for itself. Keep nested structures for isolated read-only views — for example, a print-layout report page — where the one-time transformation cost exceeds lookup benefits and the data is never mutated in-place.

How do I prevent race conditions during concurrent optimistic mutations?

Call queryClient.cancelQueries before applying the optimistic patch so any in-flight responses cannot land after your update. In Apollo, use optimisticResponse combined with update to write the cache field synchronously before the network resolves. For concurrent mutations on the same entity — for example, two users editing the same record — implement server-side version checking (ETag / If-Match) and surface the 409 conflict in onError with a merge UI rather than a silent rollback.

What staleTime and gcTime values suit high-frequency server state?

Set staleTime between 0 and 5 000 ms so background revalidation fires on every focus or reconnect event; a value of 0 means every mount triggers a background fetch if data already exists. Keep gcTime at 300 000 ms (5 min) or higher so inactive query data survives tab switches without re-fetching from scratch. For real-time feeds, pair staleTime: 0 with refetchInterval: 30_000 and WebSocket-triggered invalidateQueries on mutation events to balance freshness against bandwidth.

How do I avoid hydration mismatches between SSR cache snapshots and client state?

In React Query, call dehydrate(queryClient) on the server with a custom shouldDehydrateQuery that excludes queries containing volatile data (timestamps, session tokens). Embed the result in __NEXT_DATA__ or a <script> tag, then wrap the client tree in <HydrationBoundary state={dehydratedState}>. In Apollo, call getDataFromTree server-side and pass cache.extract() to the client’s InMemoryCache constructor via restore. Both patterns guarantee the initial render uses identical data on server and client, eliminating the React reconciliation diff.

Which library handles normalized caching automatically versus requiring manual normalization?

Apollo Client’s InMemoryCache normalizes automatically using __typename + id (or a custom keyFields policy). RTK Query’s createEntityAdapter normalizes Redux state with generated CRUD selectors. React Query and SWR treat query results as opaque blobs — they store exactly what queryFn returns, keyed by the query key. Manual normalization in React Query requires designing a normalized shape and using setQueryData to merge partial updates. The trade-off is control: Apollo’s automatic normalization requires a GraphQL selection set discipline; React Query’s manual approach works with any API shape.