●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Local Data Persistence in Rork Apps — Choosing AsyncStorage, MMKV, and SQLite by Measurement
A measured comparison of local storage in Rork apps: AsyncStorage, MMKV, and SQLite. Covers a zero-downtime migration, MMKV pitfalls, and how transactions plus WAL make SQLite bulk inserts dozens of times faster — grounded in hands-on indie development.
I had added a favorites feature to one of the apps I run as an indie developer. Everything worked smoothly up to a few hundred entries. Then a heavy user's favorites grew into the thousands, and reports came in: every time they opened the list, the screen flashed white for a beat.
The cause was that, on launch, the app loaded the entire favorites list from AsyncStorage and ran JSON.parse on one enormous array every single time. The data was just key-value, yet startup got heavier as the data grew. Back then I had told myself "storage is something I can figure out later," and that complacency came back to me as a visible drop in perceived speed.
Local storage choices don't matter while an app is small — anything works. The difference shows up once a user's data has grown. That's exactly why understanding the mechanics up front saves you from rebuilding later.
This guide compares the three staples available in React Native / Expo-based Rork apps — AsyncStorage, MMKV, and SQLite — and goes deeper than a feature tour: into why speed appears or doesn't, how to migrate safely, the pitfalls to avoid, and how to turn a "slow SQLite" into a fast one. All of it is grounded in things I actually tripped over.
AsyncStorage — The Shortest Path to Something That Works
AsyncStorage is the community-maintained key-value store for React Native. It works out of the box in projects Rork generates, and its learning curve is close to zero.
Data format: String key-value pairs (objects via JSON.stringify)
API: Fully Promise-based and asynchronous
Backend: SQLite on Android (6MB default cap), plist files on iOS
Thread safety: Internally serialized, safe under concurrent access
For settings, flags, temporary token storage, a few dozen cached entries, and MVP-stage apps, this is plenty. I personally spend the first few weeks of a new app almost entirely on AsyncStorage — in the phase where I want fast iteration, I'd rather not sink time into storage design.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why AsyncStorage slows down as data grows, plus a one-time migration to MMKV with no data loss
✦Making SQLite bulk inserts dozens of times faster with transactions and WAL, with measured behavior
✦A quick decision flow to pick AsyncStorage / MMKV / SQLite from the nature of your data
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
To understand the incident above, you need to know how AsyncStorage holds its "key-value" data.
On Android, AsyncStorage uses a single SQLite table internally and updates the relevant row on each setItem. The trouble starts when you cram a huge JSON string into one key. Adding a single item means re-serializing the entire array of thousands of entries with JSON.stringify and rewriting the whole string; reading means JSON.parse over the whole thing again. Cost grows in proportion to record count for both reads and writes.
Because it offers only an async API, there's also a flicker at startup whenever you need a value immediately — you're waiting on the await to resolve. That's a poor fit for values like theme or login state that you want settled in the very first frame.
In my experience, the perceptible thresholds land roughly here:
Up to a few dozen entries: no storage shows any difference
A few hundred: AsyncStorage's first lag becomes noticeable
Thousands or more: launch and list rendering feel clearly slow
The moment you notice "I'm stuffing an array that's likely to grow into a single key," that's your signal to migrate.
MMKV — Synchronous Storage You Can Use From the First Frame
react-native-mmkv is a React Native binding for MMKV, originally built by the WeChat team. Using memory-mapped files (mmap), it achieves read/write speeds roughly 30x faster than AsyncStorage.
Data format: Key-value with native types (strings, numbers, booleans)
API: Both synchronous and asynchronous — storage.getString() reads instantly at startup
Encryption: Optional AES-128
Multiple instances: Separate storage per purpose
import { MMKV } from 'react-native-mmkv';const storage = new MMKV({ id: 'app-storage', // encryptionKey: 'your-encryption-key', // to enable encryption});// Save and read synchronouslyconst saveTheme = (theme: 'light' | 'dark') => { storage.set('user.theme', theme); console.log(`Theme set to ${theme}`);};const loadTheme = (): 'light' | 'dark' => { const theme = storage.getString('user.theme'); return (theme as 'light' | 'dark') ?? 'light';};// Expected output:// saveTheme('dark') => "Theme set to dark"// loadTheme() => "dark"
MMKV's real strength is tying directly into React state. Its hooks connect persistence and re-rendering into a single line.
import { useMMKVString, useMMKVBoolean } from 'react-native-mmkv';function SettingsScreen() { const [username, setUsername] = useMMKVString('user.name'); const [darkMode, setDarkMode] = useMMKVBoolean('user.darkMode'); return ( <View> <Text>Username: {username ?? 'Not set'}</Text> <Switch value={darkMode ?? false} onValueChange={(value) => setDarkMode(value)} /> </View> ); // => Changing a value auto-persists to MMKV and triggers a re-render}
That single fact — being readable synchronously — eliminates startup flicker at the root. Values that should be settled in the first frame, like theme or an onboarding-complete flag, I now keep almost entirely in MMKV.
Migrating From AsyncStorage to MMKV With Zero Downtime
Even an app already running on AsyncStorage can move to MMKV without losing user data. The key is to run the migration exactly once and set a completion flag — pull existing values on the first launch after the update, and skip it from the second launch on.
import AsyncStorage from '@react-native-async-storage/async-storage';import { MMKV } from 'react-native-mmkv';const storage = new MMKV({ id: 'app-storage' });const MIGRATION_FLAG = 'migration.asyncToMmkv.v1';// Keys to migrate (list them per your app's reality)const KEYS_TO_MIGRATE = ['@user_preferences', '@last_tab', '@onboarding_done'];export const migrateAsyncStorageToMMKV = async () => { // Already migrated? Bail instantly (synchronous, cheap check) if (storage.getBoolean(MIGRATION_FLAG)) return; try { const pairs = await AsyncStorage.multiGet(KEYS_TO_MIGRATE); for (const [key, value] of pairs) { if (value !== null) { storage.set(key, value); // move as a string } } storage.set(MIGRATION_FLAG, true); // Clean up old data only after migration succeeds await AsyncStorage.multiRemove(KEYS_TO_MIGRATE); console.log(`Migrated ${pairs.length} keys to MMKV`); } catch (error) { // On failure, don't set the flag — it retries on next launch console.error('Migration failed; will retry next launch:', error); }};// Call once at your app entry point// await migrateAsyncStorageToMMKV();
Three things matter here: check the completion flag with synchronous getBoolean so the second launch costs nothing, delete the old data only after success, and on failure leave the flag unset so the next launch can retry. This way, even if the app crashes mid-migration, no data is left dangling.
Three Pitfalls You'll Likely Hit With MMKV
Fast doesn't mean foolproof. Here are the ones I actually hit, or nearly did.
Cramming huge arrays in as JSON. MMKV's set itself is fast, but the JSON.stringify / JSON.parse cost is no different from AsyncStorage. Putting a thousands-long array into a single key isn't fixed by swapping the storage engine. That's not MMKV's job — it's SQLite's, covered below.
Storing secrets in plaintext. For tokens or anything password-adjacent, set an encryptionKey, or more rigorously use iOS Keychain / Android Keystore. MMKV's encryption is convenient, but hardcoding the key itself inside the app defeats the point.
Spawning instances carelessly. Per-purpose instances are an advantage, but scattering ids makes it impossible to know "which value lives where." I keep it to roughly two — settings and cache — and organize with a naming convention (dot-separated, like user.theme).
SQLite — The Real Answer for Structured, Searchable Data
expo-sqlite is a wrapper that brings a relational database onto the device: table schemas, SQL queries, indexes, and transactions — server-like capabilities, running locally. Data like the "thousands of favorites" from the opening, which grows in count and needs filtering and sorting, belongs here by nature.
import * as SQLite from 'expo-sqlite';const db = await SQLite.openDatabaseAsync('myapp.db');await db.execAsync(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed INTEGER DEFAULT 0, category TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category);`);const addTask = async (title: string, category: string) => { const result = await db.runAsync( 'INSERT INTO tasks (title, category) VALUES (?, ?)', [title, category] ); return result.lastInsertRowId;};const getTasksByCategory = async (category: string) => { return await db.getAllAsync( 'SELECT * FROM tasks WHERE category = ? ORDER BY created_at DESC', [category] );};// Expected output:// await addTask('Create shopping list', 'daily') => 1 (lastInsertRowId)// await getTasksByCategory('daily')// => [{ id: 1, title: 'Create shopping list', completed: 0, category: 'daily', created_at: '2026-03-29 ...' }]
Indexing category keeps filtering fast even as the table grows into the tens of thousands. The rule of thumb is to index the columns you frequently use in WHERE / ORDER BY.
Turning a "Slow SQLite" Into a Fast One — WAL and Bulk Inserts in a Transaction
This is the part I most want to pass on. When SQLite feels "slow," it's usually not the database — it's how you write.
By default, SQLite performs a disk sync (fsync) on every single INSERT. Inserting a few hundred server-fetched rows one at a time with await runAsync triggers that many syncs and gets visibly slow. Wrapping them in a single transaction collapses the syncs to effectively one and makes it an order of magnitude faster.
import * as SQLite from 'expo-sqlite';const db = await SQLite.openDatabaseAsync('myapp.db');// Once at startup: WAL mode raises read/write concurrencyawait db.execAsync('PRAGMA journal_mode = WAL;');// Slow way: one at a time (N syncs)const insertSlow = async (rows: Array<{ title: string; category: string }>) => { for (const r of rows) { await db.runAsync('INSERT INTO tasks (title, category) VALUES (?, ?)', [r.title, r.category]); }};// Fast way: bulk inside a transaction (effectively 1 sync)const insertFast = async (rows: Array<{ title: string; category: string }>) => { await db.withTransactionAsync(async () => { const stmt = await db.prepareAsync( 'INSERT INTO tasks (title, category) VALUES (?, ?)' ); try { for (const r of rows) { await stmt.executeAsync([r.title, r.category]); } } finally { await stmt.finalizeAsync(); } });};// Expected tendency (varies by device and count, but the order of magnitude changes):// inserting 1,000 rows insertSlow ≈ a few seconds / insertFast ≈ ~0.1s
Two techniques combine here. PRAGMA journal_mode = WAL keeps reads and writes from blocking each other (so scrolling a list while syncing in the background stays smooth). And withTransactionAsync + prepareAsync reuses the same SQL as a prepared statement and commits everything at once.
I've had this exact experience more than once: server sync felt "oddly slow," and the culprit was inserting one row at a time. Before suspecting your data structure, suspect your write path first. That's worth remembering.
Which to Choose — Decide From the Nature of Your Data
When in doubt, ask these questions, in order, about the data you want to store.
Does it need to be settled in the very first frame at launch? If yes, MMKV. Theme, login state, onboarding-complete flag, the last open tab. Being readable synchronously is itself the value.
Will the count grow, with filtering, sorting, or aggregation? If yes, SQLite. Favorites, tasks, history, expense line items — rows that keep accumulating. With indexes and transactions, tens of thousands stay comfortable.
Neither, just a small amount you want done quickly? Then AsyncStorage is enough. Good for prototypes and small caches not worth migrating.
In real apps these aren't exclusive — combining them is the norm. Settings in MMKV, business data in SQLite: that two-tier setup is my current default.
In Practice — MMKV for Settings, SQLite for Business Data, in One App
Finally, here's an init routine that brings the two tiers together in one app. MMKV comes up synchronously and instantly; SQLite comes up asynchronously and reliably.
import { MMKV } from 'react-native-mmkv';import * as SQLite from 'expo-sqlite';// MMKV: settings and UI state (needs fast synchronous access)const settingsStorage = new MMKV({ id: 'settings' });// SQLite: business data (needs search and aggregation)let db: SQLite.SQLiteDatabase;export const initializeStorage = async () => { // MMKV is available synchronously and instantly (no startup flicker) const theme = settingsStorage.getString('theme') ?? 'light'; const lastOpenedTab = settingsStorage.getString('lastTab') ?? 'today'; // SQLite initializes asynchronously. Enable WAL at the same time db = await SQLite.openDatabaseAsync('tasks.db'); await db.execAsync(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, due_date TEXT, priority INTEGER DEFAULT 0, completed INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); `); return { theme, lastOpenedTab };};// Save UI state (synchronous, instant)export const saveLastTab = (tab: string) => { settingsStorage.set('lastTab', tab);};// Search tasks (flexible SQL)export const searchTasks = async (keyword: string) => { return await db.getAllAsync( 'SELECT * FROM tasks WHERE title LIKE ? ORDER BY priority DESC', [`%${keyword}%`] );};// Expected output:// await initializeStorage() => { theme: 'light', lastOpenedTab: 'today' }// saveLastTab('week') ← saved synchronously, instantly// await searchTasks('shopping')// => [{ id: 1, title: 'Shopping list', due_date: '2026-03-30', priority: 1, completed: 0 }]
When you build with Rork, the shortcut is to drop this division of labor straight into your prompt. Specify something concrete like: "Manage settings (theme, language, notifications) synchronously with MMKV; store tasks in expo-sqlite with WAL enabled and category search; load MMKV instantly at startup and initialize SQLite asynchronously." The generated code gets noticeably more accurate.
As your next step, take one look at the app you're building now and check whether you're stuffing a likely-to-grow array into a single key. If you find one, that's the data to move into SQLite. Thank you for reading.
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.