Why I Use MMKV Over AsyncStorage for Persisted State
A React Native case study from an offline-first fitness app build
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:
- Cold start is the first impression. If I can’t restore enough state quickly, users land in a blank screen or loading spinners.
- 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.
| Option | What it is | Pros | Cons | Best when |
| AsyncStorage (community) | Simple key/value storage, async JS API | Built-in mental model, widely used, minimal native setup | JSON serialize/parse overhead, slower reads on startup, easy to store too much, performance varies by platform/implementation | Very small state, infrequent reads, low perf sensitivity |
| SQLite for everything | Store preferences/state tables in SQLite | One database to rule them all, queryable, consistent backup story | More schema work, migrations, overkill for tiny blobs, still needs careful read patterns on startup | You need relational queries or complex local joins |
| Secure storage (Keychain/Keystore) | OS-provided encrypted storage | Great for secrets, tokens | Not meant for frequent reads/writes, capacity limits, slower | Credentials, API keys, refresh tokens |
| MMKV | Fast key/value storage via JSI (C++), sync reads | Very fast, synchronous reads (no async waterfall), good for persisted state, supports encryption | Native dependency, synchronous API can be abused, not queryable like SQLite | Hot-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:
- Startup speed via synchronous reads: restoring state doesn’t require an async chain before rendering.
- Lower overhead for small-to-medium blobs: less pain from JSON parse/stringify on hot paths.
- 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:
- 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.
- Data shape discipline matters more than the storage engine: MMKV didn’t save me from persisting too much.
partializedid. - 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
- Treat persistence as part of your performance budget, not a utility. It’s on the startup path.
- Persist references, not aggregates: store IDs and timestamps; derive the rest from SQLite or static catalogs.
- Use
partialize(or equivalents) as a guardrail to prevent “state creep.” - Prefer predictable performance over theoretical simplicity when your UX depends on speed.
- 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?