RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
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.

Rork515MMKV6AsyncStorage10wallpaper app23indie developer37storage2

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-05-28
Syncing 'Favorite Wallpapers' Across Devices with NSUbiquitousKeyValueStore in Rork iOS Apps — Implementation Notes from Six Apps Run in Parallel
For Rork-generated iOS apps, syncing a small set of favorites across devices is often better served by NSUbiquitousKeyValueStore than CloudKit. From the perspective of running six wallpaper apps in parallel, this article shares the threshold design, conflict resolution, and first-launch restore order learned in production.
Dev Tools2026-05-26
Two Weeks Tightening Up iPad Support for a Rork-Generated Wallpaper App
Notes from spending two weeks tightening up iPad support for a wallpaper app I scaffolded with Rork. Coming from an iPhone-centric indie practice since 2014, I cover where Rork's defaults stopped, how the AdMob adaptive banner misbehaved on iPad, and what changed in retention afterwards.
Dev Tools2026-05-19
Fixing 'Row too big to fit into CursorWindow' on Android When AsyncStorage Holds Too Much in Rork
When a React Native app generated with Rork stores large JSON or image metadata in AsyncStorage, Android can throw a Row too big to fit into CursorWindow exception. Here are the practical fixes — MMKV migration, chunked keys, payload trimming, and compression — explained from real wallpaper-app experience.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →