Fixing React Native AsyncStorage Performance Issues by Migrating to MMKV
App startup gets slower every month as more gets cached, and small interactions that save a preference or update a cache noticeably stutter. Nothing about that code changed — it's AsyncStorage's real per-call overhead compounding as the app's actual storage usage has grown past what it was ever efficient at.
The Problem
A React Native app's startup time creeps upward over time, correlating with how much data has accumulated in persisted storage — cached API responses, user preferences, feature flags. Interactions that read or write to storage (saving a setting, updating a local cache after an edit) produce a noticeable, visible stutter, especially on lower-end Android devices. None of this shows up as an error; it's a gradual performance degradation that's easy to attribute to "the app just getting more complex" rather than to the specific storage layer being used.
Why It Happens
Every AsyncStorage call carries real per-call overhead, and it compounds
AsyncStorage's API returning a Promise describes its calling convention, not the actual cost of an operation. Every individual read or write still involves crossing into native code and serializing data as JSON strings, and that overhead is paid per call — a code path that reads several individual keys in sequence, or writes to storage on every keystroke or interaction, pays that fixed cost multiplicatively rather than once. This is invisible with a handful of small operations and becomes a real, measurable cost only once an app's actual storage usage grows past casual scale.
AsyncStorage has no synchronous read, which forces an async round-trip onto anything gating first paint
Something like restoring a user's saved theme before the first frame renders has to wait on a full asynchronous round-trip if it's stored in AsyncStorage, since there's no way to read a value from it synchronously. That wait is exactly what shows up as visible startup delay — a splash screen or loading state that exists purely to wait out a storage read that a synchronous store wouldn't require at all.
Large JSON blobs make small changes cost the size of the whole object
Storing one large object under a single key means every write serializes the entire object, and every read deserializes all of it — so updating one field inside that object costs proportional to the object's total size, not to what actually changed. This scales badly specifically as an app's cached data grows, which is why the slowdown often appears gradual rather than present from day one.
The Fix
1. Migrate hot-path storage to react-native-mmkv
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
storage.set("theme", "dark");
const theme = storage.getString("theme"); // synchronous — no await, no round-trip
MMKV is backed by a native key-value store accessed through JSI rather than the older bridge, avoiding the per-call serialization overhead AsyncStorage carries, and it supports genuinely synchronous reads and writes. For anything on a hot path — frequently accessed preferences, cache reads at startup — this is the actual fix, not a workaround layered on top of AsyncStorage's existing cost.
2. Where AsyncStorage must remain, batch operations instead of looping individual calls
// instead of:
for (const key of keys) {
await AsyncStorage.getItem(key);
}
// batch it:
const pairs = await AsyncStorage.multiGet(keys);
multiGet/multiSet amortize the fixed per-call overhead across many keys in a single round-trip, rather than paying that cost separately for every individual key — for any storage layer still on AsyncStorage, this alone removes a significant fraction of the multiplicative cost.
3. Split large JSON blobs into smaller, independently-updatable keys
Rather than storing one large object under a single key and rewriting the whole thing for every field change, break frequently-changing data into separate keys (or move it to MMKV specifically). A single field update should cost proportional to that field, not to everything else stored alongside it.
4. Use MMKV's synchronous read specifically for anything gating first paint
function App() {
const theme = storage.getString("theme") ?? "light"; // available immediately, no loading state needed
return <ThemeProvider theme={theme}>{/* ... */}</ThemeProvider>;
}
Once theme or similar startup-critical values live in MMKV, they're available synchronously on the very first render — removing the need for a splash screen or loading state whose entire purpose was waiting out an async storage round-trip that no longer exists.
Why This Works
Each fix addresses the actual cost rather than working around its symptoms. Migrating to MMKV removes the bridge-crossing, JSON-serialization overhead at its source rather than trying to minimize how often it's paid; batching AsyncStorage calls amortizes fixed per-call cost across many operations instead of paying it individually; splitting large blobs makes the cost of a change proportional to what actually changed; and synchronous reads for startup-critical values eliminate an async wait that was never functionally necessary, just a consequence of the storage API's design.
Conclusion
AsyncStorage performance degradation that grows with an app's actual storage usage isn't a sign something is broken — it's the real, compounding per-call overhead of a bridge-based, JSON-serializing storage API becoming visible once usage scales past casual levels. Migrate frequently accessed or startup-critical data to react-native-mmkv for its native, synchronous, low-overhead storage; batch any remaining AsyncStorage calls with multiGet/multiSet; split large JSON blobs into independently-updatable keys; and use synchronous MMKV reads for anything that was previously gating first paint on an async round-trip.
