Skip to main content

Command Palette

Search for a command to run...

Why I Use MMKV Over AsyncStorage for Persisted State

A React Native case study from an offline-first fitness app build

Updated
8 min read
S

Data Engineer by day, indie hacker by night. Creator of pmhnphiring.com, building a gym tracker next. Shipping solo and writing about every win and fail. Building in public .

Offline-first apps live or die by perceived speed. In my React Native fitness app (5‑second set logging, SQLite-first), the difference between “instant” and “laggy” often comes down to one unglamorous detail: how you persist state. I initially treated persistence as an afterthought (AsyncStorage + JSON), then watched startup time and UI responsiveness degrade as I added an achievement system and more client-side state. This post is about why I switched to MMKV for persisted state with Zustand—and what I traded away to get consistently snappy interactions.

The problem space: persistence is on the hot path

I’m building a mobile workout app where the core interaction is logging a set in ~5 seconds. The app is offline-first:

  • SQLite is the primary data store (workouts, sets, exercises)
  • The UI needs to feel instantaneous (sub-100ms interactions)
  • State must survive restarts (in-progress workout, last used timers, user preferences, cached AI hints)
  • A new achievement system introduced more “derived UI state” (unlocked milestones, celebratory banners, streaks)

At my current scale (10-person waitlist, ~400+ exercises), this isn’t “big data.” But mobile performance is nonlinear: you can be “small” and still feel slow.

Two constraints made persistence a first-class architecture decision:

  1. Cold start is the first impression. If I can’t restore enough state quickly, users land in a blank screen or loading spinners.
  2. Offline-first means more local state. Remote is not the source of truth; the device is. That shifts more responsibility to local persistence.

I also build in a “vibe coding” style (Cursor + Claude). That speeds up iteration, but it also increases the risk of accidental performance regressions—so I wanted a persistence layer that’s hard to misuse.

Options considered

The decision was specifically about persisted app state (Zustand store snapshots, preferences, small caches), not the main relational data (that lives in SQLite).

Here are the options I seriously considered.

OptionWhat it isProsConsBest when
AsyncStorage (community)Simple key/value storage, async JS APIBuilt-in mental model, widely used, minimal native setupJSON serialize/parse overhead, slower reads on startup, easy to store too much, performance varies by platform/implementationVery small state, infrequent reads, low perf sensitivity
SQLite for everythingStore preferences/state tables in SQLiteOne database to rule them all, queryable, consistent backup storyMore schema work, migrations, overkill for tiny blobs, still needs careful read patterns on startupYou need relational queries or complex local joins
Secure storage (Keychain/Keystore)OS-provided encrypted storageGreat for secrets, tokensNot meant for frequent reads/writes, capacity limits, slowerCredentials, API keys, refresh tokens
MMKVFast key/value storage via JSI (C++), sync readsVery fast, synchronous reads (no async waterfall), good for persisted state, supports encryptionNative dependency, synchronous API can be abused, not queryable like SQLiteHot-path state (startup, navigation), medium-sized persisted slices

Why not just use AsyncStorage “correctly”?

You can. If you aggressively minimize what you persist, debounce writes, and avoid reading too much on boot, AsyncStorage can be fine.

But in practice, “correctly” is the hard part—especially as a solo creator moving fast.

What pushed me away:

  • Async waterfall on startup: await getItem() chains across multiple keys can add up.
  • Serialization overhead: JSON parse/stringify becomes noticeable when you persist larger objects (like achievement states or cached AI responses).
  • Non-obvious regressions: a single new persisted field can silently add milliseconds to every startup.

Why not store state in SQLite?

I’m already using SQLite heavily, so the “one local store” idea was tempting.

But I want to keep a clear boundary:

  • SQLite: durable domain data (workouts/sets/exercises), needs migrations, integrity constraints.
  • KV store: UI/session preferences and caches (fast, schema-less, easy to wipe).

Mixing them tends to create a junk-drawer schema where every new UI flag becomes a table row. It’s workable, but it’s not the kind of complexity I want early.

The decision: MMKV for persisted Zustand slices

I chose MMKV as the persistence backend for Zustand.

Ranked reasons:

  1. Startup speed via synchronous reads: restoring state doesn’t require an async chain before rendering.
  2. Lower overhead for small-to-medium blobs: less pain from JSON parse/stringify on hot paths.
  3. Better guardrails: it nudges me toward persisting only what matters, because it’s easy to keep state slices small and explicit.

What I gave up:

  • More native surface area (dependency management, Expo config/plugins)
  • Potential UI jank if I abuse sync reads/writes (sync is a tool, not a free lunch)
  • Less portability than AsyncStorage (MMKV is native-first)

Implementation overview

The key architectural choice wasn’t “use MMKV” in isolation; it was persist only the slices that must survive a restart.

My rule: if it can be recomputed from SQLite, don’t persist it in MMKV.

1) Create an MMKV-backed storage adapter for Zustand

// storage/mmkv.ts
import { MMKV } from 'react-native-mmkv'

export const mmkv = new MMKV({
  id: 'gymtracker-mmkv',
  // Optional: encryptionKey can be added, but be careful with key management.
})

export const zustandStorage = {
  setItem: (name: string, value: string) => {
    mmkv.set(name, value)
  },
  getItem: (name: string) => {
    const v = mmkv.getString(name)
    return v ?? null
  },
  removeItem: (name: string) => {
    mmkv.delete(name)
  },
}

This adapter keeps the persistence boundary clean: Zustand only sees a string-based storage interface.

2) Persist only “session-critical” state

Example: the currently active workout session (IDs, timestamps, UI mode), not the full workout history.

// state/useWorkoutSessionStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { zustandStorage } from '../storage/mmkv'

type WorkoutSessionState = {
  activeWorkoutId: string | null
  startedAt: number | null
  restTimerSeconds: number
  setActiveWorkout: (id: string | null) => void
  setRestTimerSeconds: (s: number) => void
  resetSession: () => void
}

export const useWorkoutSessionStore = create<WorkoutSessionState>()(
  persist(
    (set) => ({
      activeWorkoutId: null,
      startedAt: null,
      restTimerSeconds: 90,
      setActiveWorkout: (id) => set({ activeWorkoutId: id, startedAt: id ? Date.now() : null }),
      setRestTimerSeconds: (s) => set({ restTimerSeconds: s }),
      resetSession: () => set({ activeWorkoutId: null, startedAt: null }),
    }),
    {
      name: 'workout-session-v1',
      storage: createJSONStorage(() => zustandStorage),
      partialize: (state) => ({
        activeWorkoutId: state.activeWorkoutId,
        startedAt: state.startedAt,
        restTimerSeconds: state.restTimerSeconds,
      }),
    }
  )
)

Two important details:

  • partialize: prevents “accidental persistence” of large or unstable state.
  • versioning via key name: makes it easier to invalidate when you change shapes.

3) Don’t persist derived/large objects (store references instead)

A trap I fell into early: persisting “achievement UI state” as a big object (every milestone, last shown, UI banners). It inflated the persisted blob and increased restore time.

Instead, I persist only:

  • last shown milestone ID
  • a set of unlocked milestone IDs (small)
  • timestamps for rate-limiting celebrations

Everything else is derived from a static milestone catalog bundled with the app.

// state/useAchievementsStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { zustandStorage } from '../storage/mmkv'

type AchievementsState = {
  unlockedIds: Record<string, true>
  lastCelebratedId: string | null
  lastCelebratedAt: number | null
  unlock: (id: string) => void
  markCelebrated: (id: string) => void
}

export const useAchievementsStore = create<AchievementsState>()(
  persist(
    (set, get) => ({
      unlockedIds: {},
      lastCelebratedId: null,
      lastCelebratedAt: null,
      unlock: (id) => {
        if (get().unlockedIds[id]) return
        set((s) => ({ unlockedIds: { ...s.unlockedIds, [id]: true } }))
      },
      markCelebrated: (id) => set({ lastCelebratedId: id, lastCelebratedAt: Date.now() }),
    }),
    {
      name: 'achievements-v1',
      storage: createJSONStorage(() => zustandStorage),
      partialize: (s) => ({
        unlockedIds: s.unlockedIds,
        lastCelebratedId: s.lastCelebratedId,
        lastCelebratedAt: s.lastCelebratedAt,
      }),
    }
  )
)

This keeps MMKV as a fast “memory of the app,” not a second database.

Results & learnings (numbers + gotchas)

I don’t have millions of users, so my numbers are device-level measurements, not fleet-wide telemetry.

On a mid-range Android device (Pixel 6a-class) and an iPhone 13-class device, measured with simple timestamp logging around hydration and first interactive screen:

  • Persisted hydration time (Zustand restore):

    • AsyncStorage (before): ~35–80ms typical, with occasional 150ms spikes when the persisted blob grew
    • MMKV (after): ~5–15ms typical, fewer spikes
  • Time to first “workout screen interactive” (not full app start, but navigation + state ready):

    • Before: ~450–650ms
    • After: ~320–500ms

The bigger win wasn’t the raw numbers—it was predictability. The spikes were what made the UI feel unreliable.

Unexpected challenges:

  1. Sync APIs are easy to misuse: MMKV reads are synchronous. If you start doing lots of reads during render (especially in list items), you can create jank. My mitigation: read once in store hydration, then use in-memory state.
  2. Data shape discipline matters more than the storage engine: MMKV didn’t save me from persisting too much. partialize did.
  3. Wipe strategy: for debugging and schema changes, having a clear “reset local state” action is essential. KV stores make this easier than SQLite.

Key insight: MMKV improved the ceiling, but state-slice design removed the foot-guns.

When this doesn’t work

MMKV isn’t a universal recommendation.

Choose something else if:

  • You need complex queries over persisted data (use SQLite). KV storage is not fun once you need filtering, joins, or analytics.
  • Your persisted state is tiny and read rarely. AsyncStorage is simpler and “good enough” when you’re persisting a couple of strings.
  • You’re in a strict managed environment where adding native modules is costly (depending on your Expo setup and policies).
  • You have multi-process or cross-app access requirements. MMKV has patterns for this, but the complexity rises quickly.

Also: if your app’s main performance problem is expensive renders, heavy images/GIFs, or slow SQLite queries, switching persistence backends won’t move the needle much.

Key takeaways

  1. Treat persistence as part of your performance budget, not a utility. It’s on the startup path.
  2. Persist references, not aggregates: store IDs and timestamps; derive the rest from SQLite or static catalogs.
  3. Use partialize (or equivalents) as a guardrail to prevent “state creep.”
  4. Prefer predictable performance over theoretical simplicity when your UX depends on speed.
  5. Measure spikes, not just averages—users feel the worst 5%.

Closing

If you’re building an offline-first React Native app, what do you persist outside your primary database—and how do you keep that persisted state from quietly growing into a second system of record?