skip to content

A React Native app's Async Storage usage has grown beyond a few preferences; what signals say it is time to move data to MMKV or SQLite, and how do you migrate it safely?

level: seniorimportance: should knowfreq 44%

answer

  1. awaited read before first paint
  2. whole-string rewrite per change
  3. filtering getAllKeys in JavaScript
  4. copy, verify, mark, then delete
  5. marker written last makes reruns safe

basics

~20 s

Move when startup waits on awaited reads (synchronous MMKV fits), when large JSON strings are rewritten for one-field changes or filtered in JavaScript (SQLite fits), or when secrets crept in. Migrate once at startup: copy, verify, write a marker, then delete the old keys.

solid answer

~40 s

Async Storage is a promise-only, string-only key-value store, so three pains signal it has been outgrown. First, **latency at launch**: every preference needs an `await` before the first screen, causing a spinner or a flash of defaults; a synchronous store such as MMKV removes that wait. Second, **document-shaped data**: a big JSON array rewritten on every tap, with no partial update since v3 removed merge; that data wants rows, i.e. SQLite. Third, **query needs**: `getAllKeys()` plus filtering in JavaScript is a hand-built index. The migration itself is a one-time, rerunnable step at startup: read everything with `getAllKeys` and `getMany`, transform, write to the new store, verify, record a migration marker, and only then `removeMany` or `clear` the old instance.

go deeper

for a junior

Recall that Async Storage is for small string values and that large or query-shaped data belongs in a database instead.

for a middle

Explain why awaited reads delay first render and why storing a big JSON string means rewriting all of it on each change.

for a senior

Lay out an idempotent migration: marker check, batch read, defensive parse, write, verify, marker last, delete old keys last, and how an interrupted run recovers.

for a principal

Decide which data moves and when, balancing startup cost, the long tail of users on old versions and the risk of carrying two storage paths across releases.

## What Async Storage is built for Async Storage (`@react-native-async-storage/async-storage`, v3) is a small, **unencrypted, asynchronous key-value store** whose values are strings. Its API is deliberately narrow: `getItem`/`setItem`/`removeItem`, the batch methods `getMany`/`setMany`/`removeMany`, `getAllKeys` and `clear`. It works well for an onboarding flag and a language choice. The question is what it looks like when an app keeps adding to it. ## Signals that the data has outgrown it | Symptom | Why Async Storage causes it | Usual destination | |---|---|---| | Launch shows a spinner or a flash of the default theme or language | every read is an awaited Promise, so the first render cannot have the value | a synchronous key-value store such as MMKV | | One tap rewrites a large JSON string | values are opaque strings; v3 has no merge, so changing one item means rewriting all of them | SQLite, one row per item | | Code calls `getAllKeys()` and filters or sorts in JavaScript | there are no queries or indexes in the API | SQLite with real queries | | Keys such as `order-123`, `order-124` multiply into thousands | the key space is being used as a table | SQLite | | A token or personal data turns up in a key | the store is plaintext | secure storage | | Files or images encoded as base64 strings | strings are the only type | the file system | None of these is an emergency on its own. Together they mean the store is doing a database's job without a database's tools. Picking among the destinations is a design topic of its own; the migration mechanics below are the same whichever you choose. ## Migrating without losing data Users upgrade from every earlier version, may kill the app at any moment, and have storage you cannot inspect. A safe migration is therefore **idempotent** and **ordered**: 1. **Check a marker** in the new store. If the migration already ran, return immediately. 2. **Read everything you need** from Async Storage with `getAllKeys()` and one `getMany()`; do not read key by key. 3. **Parse defensively.** Old values may be corrupt or in shapes older code wrote; skip or repair them instead of aborting the whole migration. 4. **Write to the new store**, in a transaction if it offers one. 5. **Verify** a count or a spot check. 6. **Write the marker last.** If the app dies before this point, the next launch simply runs the migration again. 7. **Only then delete** the Async Storage keys with `removeMany()` or `clear()` on that instance. The order matters. Deleting first and writing second loses data on a crash. Writing the marker first and copying second marks a half-finished copy as done. ```typescript import { createAsyncStorage } from "@react-native-async-storage/async-storage"; const legacy = createAsyncStorage("orders"); export async function migrateOrders(target: { isMigrated(): Promise<boolean>; insertAll(rows: unknown[]): Promise<void>; markMigrated(): Promise<void>; }): Promise<void> { if (await target.isMigrated()) return; const keys = await legacy.getAllKeys(); const values = await legacy.getMany(keys); const rows: unknown[] = []; for (const raw of Object.values(values)) { if (raw === null) continue; try { rows.push(JSON.parse(raw)); } catch { // skip a corrupt entry instead of failing the whole migration } } await target.insertAll(rows); await target.markMigrated(); await legacy.clear(); } ``` ## Operational judgement - **Keep reading the old store for a release or two** only if you cannot migrate at startup, for example because the data set is large; otherwise one step is simpler. - **Measure the migration's cost** on a low-end Android device with realistic data; a slow startup migration is its own jank. - **Do not move everything.** The onboarding flag and the language code can stay where they are if launch latency is not the problem. - **Log the outcome** (keys read, rows written, entries skipped) so a partial failure in the field is visible. ## A note on v2 data Apps that started on Async Storage v2 have a second, older storage behind the package's default export. The same pattern applies: copy from the default export into a named v3 instance or into the new store, mark, then delete. Treat the two moves as one migration plan so users who skipped several app versions are handled too.

  • Why must the migration marker be written after the copy and not before?
    If the marker comes first and the app is killed mid-copy, the next launch sees the marker, skips the migration and the missing data is never copied. Written last, an interrupted run leaves no marker, so the next launch repeats the copy, which is harmless if the write is an upsert.
  • The theme flashes from light to dark on every launch. Is SQLite the fix?
    Usually not. The flash comes from awaiting an asynchronous read before the first render. A synchronous key-value store removes the wait for small values; SQLite is for data that needs rows and queries. Alternatively, hold the splash screen until the read resolves.
  • How do you migrate thousands of Async Storage keys without freezing startup?
    Read keys and values in batches with `getMany` rather than one `getItem` each, write in transactions, and measure on a low-end device. If it is still too slow, show a short progress state or migrate lazily per feature, keeping the old read path until each part is done.

saying these in an interview costs you the question

  • Deleting Async Storage keys before the new store's write succeeds
  • Writing the migration-done marker before copying the data
  • Aborting the whole migration on one corrupt JSON value
  • Moving to SQLite to fix a launch flash caused by async reads
  • Believing Async Storage can patch one field of a stored object in v3