The short answer

Use it. react-native-mmkv is the right default key-value store for a React Native app in 2026, and nothing below argues otherwise. The interesting part is that the library is deliberately thin, so three things it leaves to you end up shaping your storage layer: how you split instances, where the encryption key comes from, and what happens to the files when a user logs out. We got each of those wrong at least once. What follows is the version we ship now.

Why MMKV over AsyncStorage

The benchmark in the README is the usual pitch, and it is real, but speed was not our reason. The reason was that MMKV is synchronous. A store can read its persisted slice during module initialization and be correct before the first render, with no loading state, no hydration flag, and no race between "the screen mounted" and "the data arrived." With AsyncStorage every persisted store is a promise, and every consumer of that store inherits the promise. Across thirty stores that is a lot of inherited promises.

The second reason was instances. AsyncStorage is one bag. MMKV gives you as many separate files as you want, each with its own id, its own encryption key, and its own lifetime. That turned out to matter more than the read speed.

One instance or thirty

The README suggests exporting one storage object and reusing it. That is fine for a to-do app. For anything with accounts, split by two axes before you write a single key.

Lifetime. Some data belongs to the account and must vanish on logout: caches, queues, unseen counters, per-conversation state. Some belongs to the device and must survive logout: whether the welcome screen has been shown, notification preferences, the chosen app icon. We give every instance a scope, session or device, at construction. Session instances register with a coordinator that wipes them on logout; device instances never do. Mixing the two in one file means logout either leaks account data or destroys device preferences, and you will not notice which until a tester logs in as a second account.

Sensitivity. Some instances hold payloads that are already encrypted by a higher layer, so encrypting the file again buys nothing. Some hold decrypted content that must never touch disk in the clear. Some hold nothing anyone would care about. These want different encryption policies, and a policy is per instance, so they want different instances.

The result in our app is roughly twenty session-scoped instances, a handful of device-scoped ones, and one per-account cache whose id includes a short prefix of the account's public key, so two accounts on one phone can never read each other's cache even before the wipe runs. Thirty files sounds like a lot. In practice each one is a few kilobytes and the split is what makes everything in the next three sections possible.

What the encryption key really is

Three facts about MMKV encryption that the README states but does not dwell on.

First, MMKV is plaintext by default. It relies on the OS app sandbox, which is a real protection but not encryption. Anyone with a filesystem image of the phone, which is what a backup or a forensic extraction is, reads your keys and values as-is.

Second, the key is short. The underlying Tencent library truncates encryption keys to 16 bytes and uses AES-128 in CFB mode. react-native-mmkv added an AES-256 option in version 4.2.0 in March 2026; we ship on 4.1 as of this writing, so our keys are 16 characters of base64, which is about 96 bits of entropy. Adequate for a cache. Not something to describe as strong encryption in your marketing.

Third, and this is the one that matters, the key has to come from somewhere at runtime. The README example passes a string literal. A literal in your JavaScript bundle is a literal in every copy of your app, extractable in minutes, and encrypting with it is theater. The key has to be derived per device from something the device protects. On iOS that is a key the Secure Enclave will use but never export; on Android it is a Keystore key in the TEE or StrongBox. Our native layer holds a random data key wrapped by that hardware key, derives working keys from it with HKDF, and hands JavaScript only those derived keys; each instance's 16-character key is derived in turn from a working key with a per-instance label. The data key itself never crosses into JavaScript. How that hierarchy is built is a post of its own; the point here is that the MMKV key is the last link in a chain, not a constant.

Because the key is derived at runtime, it can be unavailable at runtime: no passcode set, a cancelled Face ID prompt, a Keystore that has wedged on a budget chipset. So every instance has to declare what happens then. We ended up with three policies:

  • plain. No file encryption. For payloads already encrypted upstream (an outbound queue of sealed messages) and for data nobody would care about (a streak counter).
  • derived, with fallback. Encrypt with the derived key; if the key is unavailable, open the file plain and log it loudly. The user is never worse off than a plain file, which is where every React Native app starts.
  • derived, required. Encrypt with the derived key; if the key is unavailable, throw. The caller keeps the data in memory only and retries later. This is for surfaces holding decrypted content such as journal captions and decrypted media posters, where a plain fallback would be a silent policy violation.

The lesson is not which policy is right. It is that "fall back to plain" is a per-surface decision that should be written next to the instance, not a library default you discover during a security review.

The pinned-instance trap

This one cost us a real bug, and it is not in the README.

MMKV core caches the first native instance it creates for a given id and returns that same object for every later open of the id, for the life of the process. On a cache hit it ignores the encryption key you pass. That is reasonable behavior for a native library; the JavaScript wrapper faithfully passes it through, and the type signature gives no hint.

Now combine it with the instance split above. Our logout wipe clears every session instance by id, and it has to work for ids the current session never opened, because a store that was never touched never registered itself. So the wipe does createMMKV({ id }).clearAll() with no key, which is fine: clearing truncates the file and needs no key. But that open pins a keyless native instance for the rest of the process. If the same process then logs in a second account and a store opens that id with its shiny derived key, the key is ignored, the store gets the keyless instance, and every write for the rest of the session lands on disk in plaintext while the caller believes it is encrypted.

The fix is small once you know: after constructing a keyed instance, call recrypt(key). The underlying re-key is an early-return no-op when the key already matches, which is every cold launch, and a full re-encrypt of the file when a keyless or differently-keyed instance was pinned, which after a wipe is an empty file. We wrapped that in a single constructor and made it the only way our code opens a keyed instance:

function createKeyedMMKV(id: string, key: string) {
  const instance = createMMKV({ id, encryptionKey: key });
  instance.recrypt(key); // converge the native instance to OUR key
  return instance;
}

Two rules follow. Never call recrypt(undefined) anywhere in the app; on a pinned keyed instance that decrypts the file in place, which is a downgrade you cannot see from JavaScript. And do not try to "fix" pinning by closing native instances: JavaScript-held handles, store singletons, memory-warning listeners, all still point at the freed object.

Promoting a store to encrypted leaves residue

A related surprise. Suppose a store started life with the fallback policy, some users hit the fallback and wrote real payloads to a plain file, and a later release promotes the store to required. The keyed open now cannot read the plain file: the checksum mismatch discards the content, and the store starts empty. That is the correct outcome. But the discard is logical. The old plaintext bytes sit in the file until something rewrites it, and for a user who never logs out, nothing ever does.

So promotion needs a one-time purge: when a required keyed open succeeds and decrypts to zero keys, truncate the file, and record in a device-scoped marker that this id has been purged. The guard conditions matter. It runs only after the hardware key has just proven healthy for that surface, and only when there is nothing readable to lose, so it can never destroy good encrypted data. It never runs on the throw path, where "key unavailable" may be a cancelled biometric prompt and the file may be perfectly healthy ciphertext.

A logout wipe has to prove itself

The naive logout is "call clearAll() on every store." Three things make that insufficient.

Registration is lazy. A store registers its wipe handler the first time it is used. Log in, never open the messages tab, log out: the messages store never registered, its file survives. The fix is a static manifest of every session-scoped id, wiped by id regardless of registration, with a dev-time warning when someone adds a session instance and forgets the manifest. Adding the instance to a list is the kind of chore that gets skipped, so the warning is not optional.

A wipe can fail. Disk full, a native exception, a wedged native instance. So after the wipe the coordinator reads back every registered instance and asserts getAllKeys().length === 0. A non-empty instance gets one retry. If it is still non-empty, the coordinator persists a small device-scoped marker listing the failed ids, and the next launch re-runs the wipe for those ids before any sign-in code touches them. If that re-wipe fails too, the app refuses to start rather than run on top of another account's data. Fail closed; a crash report is cheaper than a leak.

Do not rely on key rotation instead. It is tempting to skip all this because a new account gets a new data key and the old account's files become unreadable. That is true, and it is not isolation: unreadable ciphertext on disk is still the previous user's data, still recoverable by anyone who later obtains their key, and still a finding in any review. Truncate the bytes.

Smaller things that cost us a day each

  • Key names. A forward slash in a key name produced persist failures on one iOS 16 point release in our logs. We now derive namespacing suffixes as base64url, which has no slash or plus, and never build a key from raw user data.
  • Objects are strings. MMKV stores strings, numbers, booleans, and buffers. Every object goes through JSON.stringify, which means every object you read back is a shape you are trusting. We wrap every stored object in an envelope with a version field and a timestamp, and run a per-key validator on read that degrades a bad shape to a cache miss instead of a crash. When a serializer changes, the validator changes with it; a snapshot test enforces that.
  • Hot reload. Re-registering the same id during development is normal. Make registration idempotent by id or you will accumulate duplicate wipe handlers and chase a phantom bug.

Notes on version 4

react-native-mmkv 4.0 shipped in October 2025 as a rewrite on Nitro Modules. If you are migrating from 3.x the breaking changes are mechanical and the upgrade guide lists them: install react-native-nitro-modules, replace new MMKV() with createMMKV(), rename delete() to remove() because delete is reserved in C++, and rename the AppGroup Info.plist key if you share storage with an extension. Nitro wants React Native 0.75 or newer. The one non-mechanical change is that the MMKV core library now comes from CocoaPods and Gradle prefabs, so your own native code can use it directly, which is how our native layer and JavaScript ended up sharing instances by id.

What we keep out of MMKV

Media. Images and video go to the filesystem; MMKV is a key-value store with a memory map, not a blob store, and a few large values will make every other read pay for them. Message content: our messages are never cached to disk at all, so a device image contains no message history to decrypt. And anything we would be unhappy to find in a plaintext backup goes behind the required policy or does not persist. The question to ask of every set() is not "is this convenient to have on disk" but "who could read this file, and would that be fine."

Common questions

Is react-native-mmkv better than AsyncStorage? For most apps, yes: synchronous reads let stores hydrate before first render. AsyncStorage remains fine for small, non-sensitive, rarely-read data.

Is MMKV encrypted by default? No. It writes plaintext and relies on the sandbox. You opt in with a key, and the key has to be derived on the device at runtime; a string literal in the bundle is not encryption.

How long can the encryption key be? 16 bytes, AES-128, by default. An AES-256 option arrived in 4.2.0.

One instance or many? Many, split by lifetime and sensitivity, so you can wipe by id and give each surface its own policy.

Why is my encrypted instance writing plaintext? A keyless open of the same id earlier in the process pinned the native instance, and later keys were ignored. Converge with recrypt(key) after construction.

Related

boopr is a free photo app with no ads and no algorithm, built by a very small team that writes up what it learns. It is on the App Store and Google Play.

Want in? boopr is free. Get it on iOS and Android and see what a feed with no strangers feels like.

Back to Blog