KHD
KHD Labs Mobile & Web Studio

Stop Using Redux or Zustand as a Second Database: Local Cache vs Remote State

Why frontend teams waste months building normalized client-side stores, how JSON serialization bloat exhausts mobile memory, and what to use instead.

Khalil D.
•
Studio Principal & Systems Lead
• 8 min read
Studio Benchmark
App RAM usage: 310MB → 84MB idle memory
Problem Addressed: "Mobile app crashes on older phones due to out-of-memory errors"

At some point in 2018, the frontend community collectively decided that every mobile app needed to replicate the company’s entire relational database inside a client-side Redux store.

We routinely audit codebases where the global state store holds 15,000 serialized objects: normalized entities for users, projects, comments, permissions, notification counts, and transient UI dropdown toggles all smashed together into one massive JSON tree.

Here is what happens on an iPhone SE or a mid-range Android phone when you do this:

  1. Every time any action dispatches, selectors re-run across nested object references.
  2. If state persistence (like redux-persist) is enabled, the entire 15MB state tree gets serialized through JSON.stringify() on every navigation event and pushed to AsyncStorage or MMKV.
  3. The JavaScript garbage collector gets pegged keeping thousands of duplicate object pointers alive.

You haven’t built a state management layer. You’ve built an accidental, unindexed, slow database in RAM.

The Clear Division of Church and State

In our studio, we enforce a strict rule across all mobile and web projects: State must belong to exactly one of three buckets.

┌─────────────────────────────────────────────────────────────┐
│ 1. SERVER STATE (Remote Cache)                              │
│    Managed by TanStack Query or native caching.             │
│    Owned by the server. Expires automatically.              │
└──────────────────────────────┬──────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────┐
│ 2. PERSISTED LOCAL DATA (Structured Records)                │
│    Managed by SQLite or MMKV. Indexed on disk.              │
│    Queried with SQL predicates, NOT loaded into RAM en masse│
└──────────────────────────────┬──────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────┐
│ 3. TRANSIENT UI STATE (Local View State)                    │
│    Managed by useState or tiny signals.                     │
│    Lives and dies with the mounted screen component.        │
└─────────────────────────────────────────────────────────────┘

Notice what is missing? A monolithic global store holding raw entity records.

Query Keys Beat Global Actions

If a user opens the “Billing History” screen, why should the “Profile” screen or the “Cart” component care about those records? They shouldn’t.

Using query caching with automatic garbage collection, data is fetched, cached for its freshness window, and evicted from memory when the view unmounts:

export function useInvoiceHistory(organizationId: string) {
  return useQuery({
    queryKey: ['invoices', organizationId],
    queryFn: () => api.fetchInvoices(organizationId),
    staleTime: 1000 * 60 * 5, // 5 minutes fresh
    gcTime: 1000 * 60 * 15,   // Evict completely from RAM after 15m idle
  });
}

No dispatch actions. No reducer switch statements. No manual normalization logic.

When you stop treating JavaScript heap memory as a persistent filing cabinet, app crashes drop off a cliff. On a recent fintech audit, migrating from normalized Redux state to TanStack Query + SQLite reduced baseline RAM consumption from 310MB down to 84MB.

Old iPhones stopped getting killed by the OS watchdog, and cold start times dropped by 1.8 seconds immediately.

Engineering Consultation

Encountering this bottleneck in your application?

We profile production repositories directly with native tooling (Xcode Instruments, Android Studio Profiler) to pinpoint the exact root causes.