MMKV React Native storage: Setup, hooks & benchmarks

mmkv_storage_react_native

MMKV doesn't just optimize key-value storage, it replaces the entire I/O model AsyncStorage relies on. Where AsyncStorage serializes everything through the async bridge (or a promise-based JSI bridge in newer versions), MMKV uses JSI directly for synchronous, memory-mapped reads and writes.

That architectural difference is why teams migrating production apps see read latency drop from milliseconds to microseconds. This guide covers installation across Expo and bare workflows, instance management, the Hooks API, encryption, and the benchmark data to decide if MMKV is right for your app.

MMKV React Native storage in short

AsyncStorage's biggest cost isn't the API. It's the async bridge round trip that blocks every read behind a promise. MMKV skips that bridge entirely, using JSI to read and write key-value data synchronously in native code.

According to react-native-mmkv's own benchmark suite, that puts it at roughly 30x faster than AsyncStorage for typical read/write operations. Watch for one common pitfall on New Architecture builds: linking react-native-mmkv alongside another TurboModule can trigger a duplicate JSI installation crash if both fight over the same native runtime binding.

This guide covers setup, the Hooks API, encryption at rest, and a post-migration validation checklist. This kind of storage-layer optimization is just one piece of a broader app performance strategy for React Native apps in 2026.

What is MMKV and how does it differ from AsyncStorage

MMKV is a key-value storage engine built by Tencent, originally for WeChat, that writes directly to memory-mapped files instead of going through a serialized bridge call. react-native-mmkv, the binding maintained by mrousavy, exposes that engine to React Native through JSI rather than the old async bridge.

The architectural difference is where the I/O happens. AsyncStorage serializes every get/set call, sends it across the bridge as a JSON message, and waits for a promise to resolve on the native side. Each call round-trips through the bridge queue, even for a single small key.

MMKV skips that queue entirely: JSI lets JavaScript call into native C++ directly and synchronously, so a getString or set call returns in the same tick, no promise, no serialization overhead.

Under the hood, an MMKV instance memory-maps a file on disk into the app's address space. Reads and writes hit that mapped memory region directly, and the OS handles flushing pages to disk. According to Tencent's MMKV engineering documentation, this memory-mapped approach is what lets the library avoid the write-ahead logging overhead typical of SQLite-backed storage layers.

This is why teams migrating off AsyncStorage see the latency drop we described above concentrated almost entirely in read operations rather than writes: async reads pay the full bridge round trip, synchronous JSI reads don't.

In practice, we treat this as the main reason to pick MMKV over AsyncStorage for any app storing frequently-read user preferences, session flags, or cached API responses rather than large blobs.

Installing react-native-mmkv: Expo vs bare workflow

Expo apps install react-native-mmkv through prebuild and a config plugin; bare workflow apps install it through standard native module linking. Both paths pull in the same Nitro Modules dependency, since version 4 rebuilt the library on Nitro Modules, replacing the older JSI binding pattern from version 3 and earlier. Version 4 also renamed the constructor from new MMKV() to createMMKV(), and .delete() to .remove(). This guide uses the v3-era syntax still running in most production codebases, so adjust those two calls if you're already on v4.

Whichever path you choose, working with a solid React Native IDE makes it easier to manage native module linking and debug config plugin issues during setup.

Step Expo (Prebuild) Bare Workflow
Install npx expo install react-native-mmkv npm install react-native-mmkv react-native-nitro-modules
Native link Handled by the Expo config plugin at prebuild time pod install (iOS), Gradle sync (Android)
Config change Add plugin entry to app.json None, autolinking picks up the module
Rebuild required Yes, npx expo prebuild --clean Yes, native rebuild

According to the react-native-mmkv, version 4 requires react-native-nitro-modules as a peer dependency in every installation path, whether using Expo or bare workflow.

Once installed, creating an MMKV instance looks the same on both workflows:

import { MMKV } from 'react-native-mmkv'

const storage = new MMKV()
storage.set('age', 30)
const value = storage.getNumber('age')

A common failure mode on bare-workflow apps: an older Podfile.lock can pin a bridge-era version of a dependency, causing the app to crash on launch after adding Nitro Modules, until you force a clean pod install. Expo prebuild apps are less exposed to this, since the config plugin regenerates native project files from scratch on every prebuild.

Creating and managing MMKV instances

Every react-native-mmkv app gets a default MMKV instance for free: call useMMKV or reference the storage object without an id, and the library creates it in the app's document directory under a shared namespace. That default instance is enough for global settings, feature flags, or cached API responses.

Named instances give you isolation. Pass an id (and optionally a path or encryptionKey) and MMKV creates a separate file on disk, addressable by key from anywhere in the app:

const userStorage = new MMKV({
  id: `user-${userId}`,
  encryptionKey: sessionKey,
});
userStorage.set('lastSyncedAt', Date.now());

Per-user instances make sense whenever an app supports account switching, multi-tenant workspaces, or logout-without-app-restart flows. The alternative, prefixing every key with a user ID inside one shared instance, works until someone calls clearAll() on logout and wipes storage for every account that ever touched the device.

Mode matters too: MMKV supports multi-process mode for app extensions and share groups, which the default single-process mode doesn't handle safely. Testing these instances in Jest needs a manual in-memory implementation of set, getString, and getAllKeys, since the native module ships no test double in the official repo.

Basic Read/Write operations and JSON serialization

An MMKV instance exposes synchronous set and get methods for strings, numbers, booleans, and Uint8Array buffers, but not objects directly. For anything structured, you serialize to JSON before writing and parse on the way out.

const storage = new MMKV();

const user = { id: 42, name: 'Dana', age: 29 };
storage.set('user', JSON.stringify(user));

const raw = storage.getString('user');
const parsed = raw ? JSON.parse(raw) : null;

This fan-out pattern (one key, one serialized value) is the standard way to store any non-primitive in react-native-mmkv, since the native layer only understands the four base types. storage.delete('user') and storage.clearAll() round out the basic API.

A stale codegen cache on a New Architecture build is a common cause of silent write failures after an upgrade: clearing the build folder usually resolves it. Worth adding to any post-migration checklist: a data-parity script that diffs old AsyncStorage keys against the new MMKV store.

For unit tests, mock the module with an in-memory Map standing in for set/getString, since Jest can't load the native binary.

Using the react hooks API: useMMKVString, useMMKVObject, useMMKVBoolean

The React Hooks API in react-native-mmkv wraps the same synchronous MMKV instance behind useMMKVString, useMMKVObject, and useMMKVBoolean, re-rendering only the component that reads a given key. Each hook takes a key and an optional MMKV instance argument, and returns a [value, setValue] tuple, mirroring useState but backed by native storage instead of in-memory React state.

const [name, setName] = useMMKVString('user.name');
const [user, setUser] = useMMKVObject('user');
const [isPro, setIsPro] = useMMKVBoolean('user.isPro');

setName('Dana');
setUser({ id: 42, age: 29 });

useMMKVObject handles the JSON serialization internally, so you skip the manual JSON.stringify/parse pair from a plain get/set call. Under the hood, each hook subscribes to a change listener on that key, which is why unrelated components don't re-render when a different key updates: a real win over a global observable store.

One edge case worth flagging: unit tests need a manual mock, since the hooks call into a native module that doesn't exist in the Jest environment.

jest.mock('react-native-mmkv', () => ({
  useMMKVString: jest.fn(() => ['mocked', jest.fn()]),
}));

Without that mock, MMKV calls throw in CI before a single assertion runs.

Managing keys: getAllKeys, contains, delete, clearAll

An MMKV instance exposes getAllKeys, contains, delete, and clearAll for managing what's actually on disk, not just what's in a component's state. getAllKeys returns a string array of every key currently set, which is the fastest way to audit storage after a migration.

contains(key) checks existence without a read, useful for guarding a default value fallback before you call getString. delete(key) removes a single key. clearAll() wipes the entire MMKV instance, a common use during logout flows or when a user resets app data.

A useful validation pass after AsyncStorage-to-MMKV cutover: dump getAllKeys() from both stores and diff the arrays before deleting the old data. For Jest, mock the module rather than the native binding: jest.mock('react-native-mmkv') with a plain in-memory object matching the same key methods keeps tests deterministic without touching JSI.

Encrypting data at REST with MMKV

Encryption at rest turns on with a single constructor argument: pass encryptionKey when you create the MMKV instance, and react-native-mmkv encrypts every value with AES-CFB-128 before it touches disk, per the react-native-mmkv.

const storage = new MMKV({
  id: 'user-storage',
  encryptionKey: 'my-secret-key',
});

The pitfall we see teams miss: changing encryptionKey on an existing MMKV instance does not re-encrypt old data automatically. You need to read the plaintext values under the old key, create a fresh instance with the new key, write the values across, then call clearAll() on the original. Skip that sequence and the app silently fails to decrypt on next launch.

Store the key itself somewhere the JS bundle can't reach, ideally the iOS Keychain or Android Keystore, not a hardcoded string like the example above. If you're on Expo, the Expo config plugin handles the native linking so encryption works in a managed workflow without ejecting.

Is MMKV really faster than AsyncStorage? Benchmark data

MMKV outperforms AsyncStorage because it reads and writes synchronously through JSI, skipping the serialized bridge round-trip that AsyncStorage depends on for every getItem call. That architectural difference, not clever caching, is where the speed gap comes from.

mrousavy's official benchmark suite puts MMKV at roughly 30x faster than AsyncStorage for sequential read/write operations on the same device, using React Native's own benchmarking tooling rather than synthetic loops. Tencent's own MMKV engineering documentation attributes the underlying storage engine's speed to mmap-backed I/O and Protocol Buffer-style encoding, which avoids the JSON parse/stringify cost AsyncStorage pays on every access.

If you need help implementing these optimizations correctly, an experienced React Native development team can assess whether MMKV's speed gains translate into meaningful improvements for your app's specific data patterns.

A New Architecture edge case worth checking for: a shared MMKV instance accessed across two TurboModules can silently return stale values on an early build with an unfixed JSI binding lifecycle bug. Pin to a react-native-mmkv version that addresses it if you see this symptom.

After migration, validate with a short checklist: confirm every persisted key still resolves under the new storage engine, diff default values for keys that previously relied on AsyncStorage's undefined-return behavior, and run a full read/write pass against getAllKeys() to catch orphaned entries. For unit tests, mock react-native-mmkv directly rather than the native module, since Jest cannot resolve the JSI binding in a Node environment.

Apps still on the old bridge should note Nitro Modules requires react-native-mmkv 4.x or later for full compatibility. If you're weighing this alongside React Native's broader trade-offs, it's worth reviewing the framework's overall strengths and weaknesses before committing to a storage migration.

MMKV vs AsyncStorage: Decision matrix

MMKV wins on every axis in the table below except one: AsyncStorage still ships zero-config with React Native and needs no native linking.

Dimension MMKV AsyncStorage
Read/write Synchronous, JSI direct Asynchronous, bridge round-trip
Typical latency Sub-millisecond per key Several ms per getItem call
Size limits No practical cap, memory-mapped ~6MB default on Android's SQLite backend (source)
Encryption at rest Native AES, pass a key to new MMKV() Requires a separate wrapper library
Multi-process access Supported via file locking (Tencent MMKV design) Not guaranteed

The react-native-mmkv package exposes this through a single MMKV instance you construct once at app start, then read via the React Hooks API anywhere in the component tree. Set a default value on first read so a missing key never returns undefined into a render path.

Our view: pick MMKV by default for any app storing session tokens, feature flags, or user preference data where read frequency matters. Keep AsyncStorage only where a dependency hard-requires it and you can't route around the async call.

Migrating from AsyncStorage to MMKV without breaking state

Migrating from AsyncStorage to MMKV is a rewrite of the storage boundary, not a drop-in swap. AsyncStorage keys are strings only, while MMKV's React Hooks API expects typed getters (getString, getNumber, getBoolean). Dump the AsyncStorage store to JSON first, then batch-write into a fresh MMKV instance keyed identically, so existing screens keep working during the cutover.

This kind of performance gain matters most in production React Native apps, where every millisecond of read latency compounds across thousands of user interactions.

A known New Architecture bug worth watching for: linking react-native-mmkv alongside another TurboModule can cause a duplicate JSI installation crash on Android release builds, since both fight over the same native runtime binding. Pinning react-native-mmkv above 2.10 and disabling autolinking for the conflicting pod resolves it, per the react-native-mmkv.

This kind of low-level native module conflict is a good reminder that choices like these ripple into broader tech stack decisions for your mobile app.

Before switching production traffic, run a validation pass: compare key counts, type-check every migrated value against its AsyncStorage source, and confirm the MMKV instance's getAllKeys output matches the old store. Mock the module in Jest with a plain in-memory object, since the native bindings behind react-native-mmkv do not run under Node by default.

Wiring MMKV into Redux-persist, zustand, and testing with jest

MMKV plugs into redux-persist through a thin storage adapter, since getString/setString don't match redux-persist's async getItem/setItem contract. Wrap the MMKV instance in a factory function that returns resolved promises, then export it as the storage engine in your persist config.

import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

export const createMMKVStorage = () => ({
  setItem: (key, value) => {
    storage.set(key, value);
    return Promise.resolve(true);
  },
  getItem: (key) => {
    const value = storage.getString(key);
    return Promise.resolve(value ?? null);
  },
  removeItem: (key) => {
    storage.delete(key);
    return Promise.resolve();
  },
});

Zustand is simpler. Its persist middleware accepts any object exposing getItem, setItem, and removeItem, so the same adapter works for both without duplicating logic:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createMMKVStorage } from './mmkvStorage';

export const useUserStore = create(
  persist(
    (set) => ({
      username: '',
      setUsername: (username) => set({ username }),
    }),
    { name: 'user-storage', storage: createMMKVStorage() }
  )
);

Testing is where most teams get stuck, because react-native-mmkv links a native module that Jest can't resolve in a jsdom environment. Mock it at the module boundary, not the instance, and assert against the mock calls instead of real disk reads.

const mockSet = jest.fn();
const mockGetString = jest.fn().mockReturnValue('alice');

jest.mock('react-native-mmkv', () => ({
  MMKV: jest.fn().mockImplementation(() => ({
    set: mockSet,
    getString: mockGetString,
    delete: jest.fn(),
  })),
}));

test('persists username using MMKV in-memory storage', () => {
  const storage = createMMKVStorage();
  storage.setItem('username', 'alice');
  expect(mockSet).toHaveBeenCalledWith('username', 'alice');
  expect(storage.getItem('username')).resolves.toBe('alice');
});

This keeps unit tests fast and avoids spinning up a native bridge on every CI run, while still confirming the adapter calls the right methods with the right arguments.

One case trips up New Architecture apps: MMKV can return binary values as an ArrayBuffer instead of a string, useful for caching images or encrypted blobs directly in the key-value store. Treating that ArrayBuffer as a plain string in a shared serializer is a common source of silent data corruption, particularly after a Nitro Modules bump changes the underlying binary representation.

Add an explicit ArrayBuffer.isView() check before any string coercion. Validate row counts and key parity between old and new storage using a one-time diff script, and confirm parity before removing AsyncStorage entirely.

FAQ: MMKV React Native storage

What is an MMKV file?

An MMKV file is the memory-mapped file that a given MMKV instance uses on disk to persist key-value data, one file per id you pass to new MMKV(). Tencent's MMKV engineering docs describe it as a mix of an in-memory cache and an append-only log, which is why reads and writes stay fast. You'll see a .mmkv file per storage instance in your app's sandbox, plus a .crc file for checksums.

Is react-native-mmkv compatible with expo?

Yes, react-native-mmkv works with Expo as long as you use a development build rather than Expo Go. It ships an Expo config plugin, so npx expo prebuild links the native module automatically without ejecting. Expo Go can't load it because it requires custom native code, so it's a non-starter for that workflow.

Does MMKV work with the new architecture?

MMKV works with React Native's New Architecture through its JSI-based bindings, and newer releases build on Nitro Modules for faster native-to-JS calls. One edge case worth checking for after upgrading: a stale TurboModule codegen cache can cause silent undefined reads until you clear the build folder and rebuild clean.

Can MMKV store binary data like ArrayBuffer?

Yes: storage.set() accepts an ArrayBuffer directly, and storage.getBuffer() reads it back. This matters for caching images, encoded protobuf payloads, or session tokens without a base64 round-trip, using the same instance you already use for strings, numbers, and booleans.

Is MMKV encryption secure enough for sensitive data?

MMKV's encryption at rest uses AES-CFB-128 with a per-instance key you supply, which is adequate for app-level secrets like auth tokens. It isn't a substitute for OS-level secure storage such as Keychain or Keystore for the highest-sensitivity data. Pair MMKV encryption with those for defense in depth.

How do I test MMKV with jest?

Mock react-native-mmkv in Jest by replacing the native module with a plain JavaScript Map-backed object exposing getString, set, and delete. Add it to jest.config.js under moduleNameMapper so unit tests never touch the native layer.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business