Day 45: Offline-First Architecture: MMKV, SQLite & Sync Engine Design
Design a mobile app that is fully usable offline by treating local storage as the source of truth and the network as an eventually-consistent sync target.
Study
Concepts
Local-first: read/write local storage, sync in the background
An offline-first app never blocks a read or write on network availability — the UI always reads from and writes to LOCAL storage immediately, and a separate sync engine reconciles local changes with the server when connectivity allows. For simple key-value needs (auth tokens, feature flags, small settings objects), MMKV (a fast, synchronous key-value store built for RN) is the right tool — synchronous access avoids the async-storage round trip for data read on every app launch. For structured, queryable, relational data (a todo list, an offline product catalog, message history), SQLite (via `expo-sqlite`, `op-sqlite`, or WatermelonDB's abstraction over it) is the right tool — it supports real queries, indexes, and relations that a flat key-value store cannot.
AsyncStorage (the older RN default) is asynchronous and stores everything as strings with no query capability — acceptable for small, infrequent data, but the wrong choice for anything performance-sensitive or structured; MMKV and SQLite have both largely superseded it for serious offline-first apps.
The sync engine: the actual hard part
A sync engine must handle: queuing writes made while offline (a durable local outbox of pending mutations), replaying that outbox when connectivity returns, and CONFLICT RESOLUTION when the same record was changed both locally and on the server since the last sync — common strategies are last-write-wins (simple, but can silently drop a user's offline change), server-wins, or a merge function specific to the data shape (e.g. merging two independently-edited fields of the same record instead of picking one whole record).
Every locally-created record needs a client-generated stable ID (a UUID, not a server auto-increment integer) so it can be referenced and related to other local records BEFORE the server has ever seen it — reconciling a client-generated ID with a server ID after first sync is a common, easy-to-underestimate source of bugs if not designed for from the start.
See It
Visualizations
Visualization
Local-first read/write path with background sync
Visualization
Sync engine cycle
write applied to local DB + queued in outbox
sync engine wakes up
replay queued mutations to the server
fetch anything changed elsewhere since last sync
merge / last-write-wins / server-wins
Build It
Code Examples
MMKV for fast, synchronous key-value access
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
// Synchronous — no await, no async round trip, safe to read on app launch
storage.set('auth.token', token);
const token = storage.getString('auth.token');
storage.set('settings', JSON.stringify({ theme: 'dark', notifications: true }));
const settings = JSON.parse(storage.getString('settings') ?? '{}');A minimal outbox pattern for offline writes
import { randomUUID } from 'expo-crypto';
// 1. Every local write gets a client-generated stable ID immediately
async function createTodoOffline(text) {
const todo = { id: randomUUID(), text, done: false, updatedAt: Date.now() };
await db.execute('INSERT INTO todos (id, text, done, updated_at) VALUES (?, ?, ?, ?)',
[todo.id, todo.text, 0, todo.updatedAt]);
// 2. Queue it in a durable outbox for later sync — survives app restarts
await db.execute('INSERT INTO outbox (id, type, payload) VALUES (?, ?, ?)',
[randomUUID(), 'CREATE_TODO', JSON.stringify(todo)]);
return todo; // UI updates INSTANTLY — no network wait
}
// 3. When connectivity returns, replay the outbox
async function flushOutbox() {
const pending = await db.query('SELECT * FROM outbox ORDER BY id ASC');
for (const entry of pending) {
try {
await api.send(entry.type, JSON.parse(entry.payload));
await db.execute('DELETE FROM outbox WHERE id = ?', [entry.id]);
} catch (err) {
break; // stop on first failure — preserve order, retry later
}
}
}Remember
Key Takeaways
- Offline-first means the UI reads/writes LOCAL storage first, always — the network is a background sync target, never a blocking dependency.
- MMKV: fast, synchronous key-value storage — right for tokens/settings. SQLite: structured, queryable, relational — right for real datasets.
- A durable local outbox of pending mutations is what makes offline writes survive app restarts and eventually reach the server.
- Conflict resolution (last-write-wins, server-wins, or a field-level merge) must be a deliberate design choice, not an afterthought.
- Client-generated stable IDs (UUIDs) let locally-created records be referenced before the server has ever seen them — plan for this from day one.
Do It
Practice
- 1Design (on paper) the outbox table schema and flush logic for an offline-capable note-taking app with create/edit/delete.
- 2Implement the MMKV example and confirm reads work correctly with the app in airplane mode.
- 3Write out, in words, your chosen conflict-resolution strategy for a hypothetical shared shopping list app edited by two people offline simultaneously, and justify the tradeoff.