◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-28Intermediate

Three Weeks of Moving Six Wallpaper Apps from AsyncStorage to MMKV in Rork

Notes from three weeks of gradually moving six wallpaper apps from AsyncStorage to react-native-mmkv. Personal write-up from an indie developer who has been shipping iOS and Android apps since 2014.

Rork585MMKV6AsyncStorage10wallpaper app22indie developer43storage

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 CursorWindow size 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.

  1. Do not lose existing user data. Some wallpaper-app users have three years of favorites saved. Losing them silently would be unforgivable.
  2. Leave a way back. If something goes wrong with MMKV, AsyncStorage must still hold the original data for at least two releases.
  3. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-06-22
When a Poisoned Cache Crashes Your App on Every Launch — Designing a Safe-Mode Boot Your Users Can Escape On Their Own
When a persisted cache goes bad and the app crashes at the same spot on every launch, the only option left to the user is to reinstall. This article designs a safe-mode boot for Expo (React Native): the app counts its own early crashes, confirms a launch only once it becomes interactive, and resets just the dangerous state in graduated steps.
⬡ Dev Tools2026-05-23
Auditing Privacy Manifests for Rork-Generated Expo Apps — A One-Day Pre-Submission Workflow for Indie Developers
A pre-submission workflow for indie developers shipping Rork-generated Expo apps: enumerate every dependency, tell ITMS-91053 apart from ITMS-91061, and catch the Pods that npm names never show you — hermes included.
⬡ Dev Tools2026-05-18
Adapting Your Rork iOS App to iPhone Air and 17 Pro Series — Layout Patterns for 2026's New Resolutions
iPhone Air introduced a 420pt width that sits between the standard and Max models, quietly breaking layout conditionals in existing Rork apps. Based on updating four iOS apps at once, this covers device constant management, Safe Area handling, and full-screen image display fixes.