Building Offline-First Mobile Applications
Sync engines, conflict resolution, local-first storage, and the patterns that make mobile apps work without an internet connection.
Mobile users aren't always connected. Elevators, subway tunnels, rural areas, airplane mode, spotty WiFi — offline scenarios are not edge cases, they're everyday reality. Offline-first design means your app works fully without a connection and syncs when connectivity returns. This is fundamentally different from 'graceful degradation' — it's a core architecture decision that affects every layer of your stack.
Local-First Architecture
In an offline-first app, the local database is the source of truth, not the server. Reads always go to the local database (instant, no loading states). Writes go to the local database first, then sync to the server in the background. This inverts the traditional client-server model — the server is the sync partner, not the authority.
- Local storage: SQLite (via Expo SQLite or WatermelonDB) for structured data. AsyncStorage for key-value preferences.
- Write-ahead log: Every local mutation is recorded in a sync queue before being applied to the local database.
- Background sync: When connectivity returns, the sync queue is replayed to the server. Use exponential backoff for failed syncs.
- Optimistic UI: Show the result of the user's action immediately based on the local write. If the server rejects it, roll back and notify the user.
Conflict Resolution
When two users edit the same data offline and both sync, you have a conflict. There's no universal solution — the right strategy depends on your domain. Last-write-wins is simplest but lossy. Operational transforms (OT) and CRDTs (Conflict-free Replicated Data Types) can merge concurrent changes automatically for collaborative editing. For most business apps, we use field-level last-write-wins with conflict detection and user-facing resolution.
interface SyncConflict<T> {
localVersion: T;
serverVersion: T;
baseVersion: T; // Common ancestor
}
function resolveConflict<T extends Record<string, unknown>>(
conflict: SyncConflict<T>
): T {
const resolved = { ...conflict.serverVersion };
// Field-level merge: if only one side changed a field, take that change
for (const key of Object.keys(conflict.baseVersion)) {
const localChanged = conflict.localVersion[key] !== conflict.baseVersion[key];
const serverChanged = conflict.serverVersion[key] !== conflict.baseVersion[key];
if (localChanged && !serverChanged) {
// Only local changed this field — take local
resolved[key] = conflict.localVersion[key];
} else if (localChanged && serverChanged) {
// Both changed — flag for manual resolution
throw new ConflictError(key, conflict.localVersion[key], conflict.serverVersion[key]);
}
// If only server changed or neither changed, keep server version (default)
}
return resolved as T;
}Testing Offline Scenarios
Offline behavior must be tested explicitly. Simulate network conditions in your test suite: no connectivity, intermittent connectivity, slow connections, and connectivity restoration during mid-operation. Use airplane mode on physical devices for manual testing — emulator network simulation doesn't perfectly replicate real-world conditions.
Design your UI for the offline state first, then add the connected state as an enhancement. If your app works beautifully offline, it'll work even better online.
Offline-first is harder to build than online-only, but the user experience is dramatically better. Instant responses, no loading spinners, no 'connection lost' error screens. For mobile apps where connectivity is unreliable, offline-first isn't a feature — it's the foundation of a good user experience.
Priya Patel
Senior Backend Engineer