Offline-First Mobile Architecture: Building Apps That Work Without the Internet
Most mobile apps are built network-first: the app requests data from the server, shows it, and expects the user to always be online. When network is unavailable - tunnel, subway, weak signal, roaming off - the app either shows an error screen or freezes.
Offline-first inverts this model: the app's source of truth is local storage. Every read comes from the local database. Every write goes to local storage first, then syncs to the server when connected. The app works identically with or without network - sync happens in the background.
This is how apps like Notion, Linear, Figma, and WhatsApp work. Here's the architecture.
When offline-first is necessary
Not every app needs offline-first. The complexity is real. Consider it when:
- Your users have unreliable connectivity (field workers, travelers, rural areas, Central Asia)
- App failure during network unavailability causes significant user friction
- Users expect to continue working mid-task without interruption
- Data creation (notes, forms, orders) must not be lost during connectivity gaps
For a read-heavy app (news, social feed, ecommerce browsing): cache aggressively, but full offline-first may be overkill. For a write-heavy app (task management, forms, messaging): offline-first is essential.
The core model: local database as source of truth
User interacts with app
↓
Write to local database (SQLite / WatermelonDB / MMKV)
↓
UI re-renders from local database (instant, no network needed)
↓
Sync queue picks up change
↓
When network available → sync to server
↓
Server responds → merge server changes into local database
The key shift: there's no "waiting for server response" in the user flow. The write is instant because it goes to local storage first. The network sync is a background concern.
Storage options
WatermelonDB (complex relational data)
The best choice for apps with relational data that needs offline-first. SQLite-backed, lazy-loaded, designed specifically for offline sync.
// schema.js
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'tasks',
columns: [
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'is_completed', type: 'boolean' },
{ name: 'project_id', type: 'string', isIndexed: true },
{ name: 'server_id', type: 'string', isOptional: true },
{ name: 'sync_status', type: 'string' }, // 'synced' | 'created' | 'updated' | 'deleted'
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'projects',
columns: [
{ name: 'name', type: 'string' },
{ name: 'server_id', type: 'string', isOptional: true },
{ name: 'sync_status', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
});
// models/Task.js
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, relation } from '@nozbe/watermelondb/decorators';
export class Task extends Model {
static table = 'tasks';
static associations = {
projects: { type: 'belongs_to', key: 'project_id' },
};
@field('title') title;
@field('description') description;
@field('is_completed') isCompleted;
@field('sync_status') syncStatus;
@readonly @date('created_at') createdAt;
@date('updated_at') updatedAt;
@relation('projects', 'project_id') project;
}
MMKV (simple key-value, ultra-fast)
For app state, user preferences, small data sets. 10x faster than AsyncStorage.
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV({
id: 'app-storage',
encryptionKey: 'your-encryption-key', // Encrypt sensitive data
});
// Synchronous reads - no await needed
const userId = storage.getString('userId');
const preferences = JSON.parse(storage.getString('preferences') || '{}');
// Sync writes
storage.set('lastSync', Date.now().toString());
storage.set('draft_post', JSON.stringify(draftContent));
Write path: optimistic updates
// Task creation: write locally first, sync later
async function createTask(title, projectId) {
// Generate a local ID immediately
const localId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Write to local database synchronously
await database.write(async () => {
await database.get('tasks').create(task => {
task.title = title;
task.projectId = projectId;
task.isCompleted = false;
task.syncStatus = 'created'; // Flagged for sync
});
});
// UI updates immediately - user sees the new task
// No spinner, no waiting
// Add to sync queue (background)
syncQueue.add({ type: 'CREATE_TASK', localId, data: { title, projectId } });
}
// Task completion: optimistic update
async function completeTask(taskId) {
await database.write(async () => {
const task = await database.get('tasks').find(taskId);
await task.update(t => {
t.isCompleted = true;
t.syncStatus = t.syncStatus === 'created' ? 'created' : 'updated';
t.updatedAt = Date.now();
});
});
syncQueue.add({ type: 'UPDATE_TASK', id: taskId, data: { isCompleted: true } });
}
The sync engine
class SyncEngine {
private isSyncing = false;
private syncInterval: ReturnType<typeof setInterval> | null = null;
start() {
// Sync on network state changes
NetInfo.addEventListener(state => {
if (state.isConnected) this.sync();
});
// Periodic sync every 30 seconds when connected
this.syncInterval = setInterval(async () => {
const { isConnected } = await NetInfo.fetch();
if (isConnected) this.sync();
}, 30_000);
// Sync on app foreground
AppState.addEventListener('change', state => {
if (state === 'active') this.sync();
});
}
async sync() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
// 1. Push local changes to server
await this.pushLocalChanges();
// 2. Pull server changes
await this.pullServerChanges();
} catch (err) {
console.warn('Sync failed:', err);
// Will retry on next sync cycle
} finally {
this.isSyncing = false;
}
}
private async pushLocalChanges() {
const unsyncedRecords = await database.get('tasks')
.query(Q.where('sync_status', Q.notEq('synced')))
.fetch();
for (const record of unsyncedRecords) {
try {
if (record.syncStatus === 'created') {
const serverRecord = await api.post('/tasks', record.toJSON());
await database.write(async () => {
await record.update(r => {
r.serverId = serverRecord.id;
r.syncStatus = 'synced';
});
});
} else if (record.syncStatus === 'updated') {
await api.put(`/tasks/${record.serverId}`, record.toJSON());
await database.write(async () => {
await record.update(r => { r.syncStatus = 'synced'; });
});
} else if (record.syncStatus === 'deleted') {
await api.delete(`/tasks/${record.serverId}`);
await database.write(async () => { await record.destroyPermanently(); });
}
} catch (err) {
if (err.status === 409) {
// Conflict - server has a newer version
await this.resolveConflict(record, err.serverRecord);
}
// Other errors: leave sync_status as-is, retry next cycle
}
}
}
private async pullServerChanges() {
const lastSync = storage.getString('lastSyncTimestamp') || '1970-01-01T00:00:00Z';
const { changes, serverTimestamp } = await api.get('/sync', {
since: lastSync,
userId: currentUserId,
});
await database.write(async () => {
for (const change of changes) {
const existing = await database.get(change.table)
.query(Q.where('server_id', change.id))
.fetch();
if (change.deleted) {
existing[0] && await existing[0].destroyPermanently();
} else if (existing[0]) {
// Only apply if server version is newer than local
if (change.updatedAt > existing[0].updatedAt) {
await existing[0].update(r => Object.assign(r, change.data));
}
} else {
await database.get(change.table).create(r => {
Object.assign(r, change.data, { serverId: change.id, syncStatus: 'synced' });
});
}
}
});
storage.set('lastSyncTimestamp', serverTimestamp);
}
}
Conflict resolution
When two clients modify the same record offline and both sync:
Last-write-wins (simplest): whichever update has the later updated_at timestamp wins. Fast to implement, loses the other change. Acceptable for most fields (task title, status).
Field-level merging (safer): merge non-conflicting field changes, flag conflicting fields for user resolution.
async function resolveConflict(localRecord, serverRecord) {
// Determine which fields conflict
const conflicts = [];
const merged = { ...serverRecord };
for (const field of editableFields) {
if (localRecord[field] !== serverRecord[field]) {
if (localRecord.updatedAt > serverRecord.updatedAt) {
// Local is newer - use local value
merged[field] = localRecord[field];
} else {
// Server is newer - use server value, but note conflict
conflicts.push({ field, localValue: localRecord[field], serverValue: serverRecord[field] });
}
}
}
await database.write(async () => {
await localRecord.update(r => Object.assign(r, merged, { syncStatus: 'synced' }));
});
// If there were meaningful conflicts, notify user
if (conflicts.some(c => importantFields.includes(c.field))) {
notifyUser('Some of your changes conflicted with changes made on another device.');
}
}
For CRDTs (conflict-free replicated data types) which handle merging mathematically without conflicts: see Yjs or Automerge for collaborative use cases. Adds significant complexity - only warranted for truly collaborative real-time editing.
UX patterns for offline state
The app should always communicate network and sync state without being annoying:
function SyncStatusBar() {
const { isConnected } = useNetInfo();
const { pendingChanges, isSyncing } = useSyncStatus();
if (isConnected && !pendingChanges && !isSyncing) return null;
return (
<View style={styles.statusBar}>
{!isConnected && (
<Text>Offline - changes saved locally</Text>
)}
{isConnected && isSyncing && (
<Text>Syncing...</Text>
)}
{isConnected && pendingChanges > 0 && !isSyncing && (
<TouchableOpacity onPress={syncEngine.sync}>
<Text>{pendingChanges} changes pending - tap to sync</Text>
</TouchableOpacity>
)}
</View>
);
}
Show sync status, not loading spinners. In an offline-first app, there should be no loading states for reading data - it comes from the local database instantly. The spinner should only appear during explicit sync operations the user initiates.
Optimistic deletes. Mark as deleted locally and hide from UI immediately. Only delete from server on sync. If sync fails for the delete, the item reappears - acceptable, since the user sees a sync error.
Testing offline behavior
// In tests: simulate offline state
it('creates task while offline and syncs on reconnect', async () => {
// Go offline
mockNetInfo.setConnectionState({ isConnected: false });
// Create task
await createTask('Test task', projectId);
// Task is in local DB with sync_status = 'created'
const localTasks = await getTasks();
expect(localTasks).toHaveLength(1);
expect(localTasks[0].syncStatus).toBe('created');
// Server has nothing yet
expect(mockApi.calls('/tasks')).toHaveLength(0);
// Come back online
mockNetInfo.setConnectionState({ isConnected: true });
await syncEngine.sync();
// Task was synced
expect(mockApi.calls('POST /tasks')).toHaveLength(1);
const syncedTask = await getTasks();
expect(syncedTask[0].syncStatus).toBe('synced');
expect(syncedTask[0].serverId).toBeTruthy();
});
Test the conflict scenario explicitly. Offline conflicts are rare in testing and common in production. Simulate two clients modifying the same record, then sync both, and verify the result is correct.
The server sync endpoint
Your server needs a dedicated sync endpoint:
// GET /sync?since=2025-01-01T00:00:00Z&userId=123
app.get('/sync', requireAuth, async (req, res) => {
const { since } = req.query;
const userId = req.user.id;
const changes = await db.query(`
SELECT
table_name as "table",
record_id as id,
operation as type, -- 'insert', 'update', 'delete'
record_data as data,
changed_at as "updatedAt"
FROM change_log
WHERE
user_id = $1
AND changed_at > $2
ORDER BY changed_at ASC
LIMIT 1000
`, [userId, since]);
res.json({
changes,
serverTimestamp: new Date().toISOString(),
});
});
Maintain a change_log table that records every insert/update/delete via database triggers. This is simpler and more reliable than trying to query multiple tables for changes.
Offline-first is an investment. The sync engine, conflict resolution, and UX patterns add 2-3 weeks to initial development. The return: an app that works reliably for all users, regardless of connectivity - which in markets like Central Asia with variable mobile data is not a nice-to-have, but a requirement for real-world usability.
Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.
Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development