I have been building iOS and Android apps on my own since 2014, and somewhere past 50 million cumulative downloads I started second-guessing my long-held belief that "AsyncStorage is enough for almost everything." The trigger was wallpaper-app users whose favorites lists had grown into the thousands. They began writing in to say that startup felt heavier than before.
When I measured it informally, cold start was around 0.4 to 0.6 seconds slower for those users. Profiling showed AsyncStorage.multiGet sitting inside a chain of promises that quietly stretched the time from process launch to the first interactive frame. So I decided to lean into MMKV, app by app, across the six wallpaper apps I currently maintain. These are notes from those three weeks.
Both of my grandfathers were temple carpenters. Growing up around them taught me the value of repair without breaking, and I tried to keep that posture in this migration: do not lose anyone's data, and leave a way back.
Why I Started Questioning AsyncStorage
AsyncStorage has been the default in React Native for as long as I can remember, and I have used it in wallpaper apps since around 2018. There has been almost no production crash from it, and no single complaint big enough to justify swapping it out.
What pushed me over the line was three small things stacking up:
- For users with more than 5,000 saved favorites, the first read at launch was visibly slow
- Every call is asynchronous, which plays fine with Hermes, but makes synchronous init blocks awkward to write
- On Android, the
CursorWindowsize limit started showing up once or twice in Crashlytics for power users
The CursorWindow warning was the one I disliked the most. To remove it cleanly you either change the data shape or the storage engine. MMKV uses mmap under the hood, so its size ceiling has a different character entirely. That was the main reason I committed to migrating.
Three Worries I Wrote Down First
Before touching code I wrote down three concerns, because rolling six apps at once is a recipe for parallel disasters.
- Do not lose existing user data. Some wallpaper-app users have three years of favorites saved. Losing them silently would be unforgivable.
- Leave a way back. If something goes wrong with MMKV, AsyncStorage must still hold the original data for at least two releases.
- Do not ship six apps at once. Run one app for a full week first, then fan out to the remaining five.
The third one is obvious in theory but hard in practice. As an indie developer, the temptation is to push everything because the codebase is shared. In return for the patience, I often catch issues in one app's Crashlytics first and avoid the same blast radius in the others.
The Wrapper I Actually Shipped
I built a small wrapper that does three things: initializes the MMKV instance, migrates legacy AsyncStorage keys on first launch, and transparently falls back to AsyncStorage on reads when a key is not yet in MMKV. Writes go to MMKV only.
import { MMKV } from "react-native-mmkv";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const storage = new MMKV({ id: "wallpaper-app-default" });
const MIGRATION_FLAG_KEY = "__mmkv_migrated__v1";
export async function migrateLegacyKeys(keys: string[]) {
if (storage.getBoolean(MIGRATION_FLAG_KEY)) return;
const pairs = await AsyncStorage.multiGet(keys);
pairs.forEach(([k, v]) => {
if (v != null) storage.set(k, v);
});
storage.set(MIGRATION_FLAG_KEY, true);
}
export async function readString(key: string): Promise<string | null> {
const inMmkv = storage.getString(key);
if (inMmkv !== undefined) return inMmkv;
const legacy = await AsyncStorage.getItem(key);
if (legacy != null) storage.set(key, legacy);
return legacy;
}
export function writeString(key: string, value: string) {
storage.set(key, value);
}Two design choices matter here. First, migrateLegacyKeys runs once at first launch and sets a flag, so it never runs again. Second, the read path also has a fallback. That way, if I forget to enumerate some legacy key in the migration list, it gets pulled and back-filled the first time someone touches it. The asymmetric "single writer, dual reader" structure made me sleep better.
What Three Weeks of Production Revealed
After one week on one app and two more weeks on the remaining five, here is what I noticed.
Startup feels lighter. From my iPhone 12 Pro and Pixel 7, the "settings read to first paint" window shrank by roughly 120 to 180 milliseconds on average. On a device with 8,000 favorites accumulated over three years, the same window shrank by 250 to 300 milliseconds. AdMob's foreground request burst also seemed to spread out, because nothing else was competing for the main thread quite as hard at launch.
Code reads more naturally. This was a pleasant surprise. Synchronous reads remove a lot of async/await from places where it was only there because of AsyncStorage. Initializing React state with useMemo(() => storage.getString("themeMode") ?? "system", []) is a small thing, but it changes how I write screens.
Crashlytics stayed quiet. In three weeks there have been zero storage-related crashes added. The CursorWindow warnings were too rare to be meaningful, but at least I have not introduced any new signal.
Three Places I Got Stuck
These are the three rough edges I wish someone had warned me about. Sharing them in case anyone else is doing the same migration.
1. MMKV does not run in Expo Go. Because MMKV depends on JSI, it crashes immediately in Expo Go. You need a Dev Client or a bare workflow. I had been meaning to move to EAS Build with a custom Dev Client anyway, so I treated this as the nudge I needed.
2. Typed getters punish loose key naming. AsyncStorage stores strings, so naming conventions can be relaxed. MMKV splits by type with getString, getNumber, and getBoolean. I once accidentally read a JSON-serialized favorites list with getNumber, got back 0, and spent a few confused hours wondering where the data went. After that I started using type suffixes like favorites_json__ and launch_count__num__.
3. Jest mocking is a little fiddly. Stubbing MMKV inside Jest takes more work than stubbing AsyncStorage. I ended up with a minimal mock that wraps a Map, something like jest.mock("react-native-mmkv", () => ({ MMKV: jest.fn().mockImplementation(() => new Map()) })). It is not pretty, but it stabilized CI within two days. Anything more complex I just push to Detox at the E2E layer.
What I Plan To Try Next
After three weeks, five of the six wallpaper apps are running with MMKV at the center. The sixth has a peculiar favorites schema that I want to redesign during the migration itself, so I split that into a separate task.
The next experiment is to split the MMKV id into "shared between apps" and "private to this app." Things like AdMob consent state and language settings could legitimately be shared, while favorites and viewing history clearly should not be. Once that is in place, the natural follow-up is to combine MMKV with App Group containers so that iOS widgets can read the same store directly. I expect that part to need its own write-up.
If you maintain wallpaper apps or content apps as an indie developer, I hope this gives you a useful reference point. Thanks for reading this far.