Why I Use MMKV Over AsyncStorage for Persisted State
A React Native case study from an offline-first fitness app build
Search for a command to run...
A React Native case study from an offline-first fitness app build
No comments yet. Be the first to comment.
Atomic multi-table writes in React Native without a heavy event system
Keeping 50ms p95 while 8,000+ listings churn daily
A builder’s take on macro labor stats: ingesting 500+ sources daily, normalizing messy postings, and surfacing “career confidence” as data you can query.
A builder’s breakdown of our CA pipeline: multi-source ingestion, dedupe, geo resolution, and what “highest-volume state” means in real data.
PMHNP-BC is more than alphabet soup: it’s an ANCC board-cert gate that drives filtering, matching, and credential verification in hiring pipelines.
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.
I’m building a mobile workout app where the core interaction is logging a set in ~5 seconds. The app is offline-first:
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:
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.
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 |
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:
await getItem() chains across multiple keys can add up.I’m already using SQLite heavily, so the “one local store” idea was tempting.
But I want to keep a clear boundary:
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.
I chose MMKV as the persistence backend for Zustand.
Ranked reasons:
What I gave up:
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.
// 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.
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.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:
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.
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):
Time to first “workout screen interactive” (not full app start, but navigation + state ready):
The bigger win wasn’t the raw numbers—it was predictability. The spikes were what made the UI feel unreliable.
Unexpected challenges:
partialize did.Key insight: MMKV improved the ceiling, but state-slice design removed the foot-guns.
MMKV isn’t a universal recommendation.
Choose something else if:
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.
partialize (or equivalents) as a guardrail to prevent “state creep.”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?