Offline-first mobile apps work fully without internet connectivity and sync when a connection is available. This requires local storage, optimistic UI updates, conflict resolution logic, and careful sync ordering. Claude Code generates offline-first patterns correctly: understanding the difference between optimistic UI and eventual consistency, proper conflict detection, and sync queue management.
This guide covers offline-first React Native with Claude Code: local SQLite storage, optimistic updates, sync queues, and conflict resolution.
CLAUDE.md for Offline-First Apps
## Offline-First Stack
- React Native with Expo
- Local storage: expo-sqlite (SQLite) for relational data, MMKV for key-value
- Sync: custom queue worker, not background fetch (unreliable on mobile)
- State: Zustand + TanStack Query with custom persister
- Conflict resolution: last-write-wins with server-authoritative fallback
## Offline Patterns
- All mutations write to local SQLite first, then queue for sync
- UI reads from local state — never from network directly
- Sync runs: on app foreground, on network reconnect, on mutation (optimistic)
- Server wins on conflict for critical data (payments); client wins for user prefs
- Always show sync status indicator so user knows their state
Local SQLite Setup
Set up SQLite schema for an offline-first todo app with tags.
Include the sync metadata columns needed to track changes.
// src/db/schema.ts
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('app.db');
export function initializeDatabase() {
db.execSync(`
PRAGMA journal_mode = WAL; -- Better concurrent read performance
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY, -- Client-generated UUID
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
tag_ids TEXT NOT NULL DEFAULT '[]', -- JSON array
-- Sync metadata
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
updated_at INTEGER NOT NULL, -- Used for conflict detection
synced_at INTEGER, -- NULL = pending sync
server_updated_at INTEGER, -- Last known server version
deleted INTEGER NOT NULL DEFAULT 0, -- Soft delete for sync
sync_status TEXT NOT NULL DEFAULT 'pending' -- pending|synced|conflict
);
CREATE INDEX IF NOT EXISTS idx_todos_sync ON todos(sync_status, synced_at);
CREATE INDEX IF NOT EXISTS idx_todos_updated ON todos(updated_at DESC);
-- Sync queue: mutations waiting to be sent to server
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL, -- 'todo', 'tag', etc.
entity_id TEXT NOT NULL,
operation TEXT NOT NULL, -- 'create', 'update', 'delete'
payload TEXT NOT NULL, -- JSON of the mutation
created_at INTEGER NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_queue_created ON sync_queue(created_at ASC);
`);
}
export { db };
Optimistic Updates
Write the create todo function. It should:
1. Write to SQLite immediately (optimistic)
2. Update UI instantly
3. Queue the sync
4. Handle sync failure gracefully
// src/store/todos.store.ts
import { create } from 'zustand';
import { db } from '../db/schema';
import * as Crypto from 'expo-crypto';
interface Todo {
id: string;
title: string;
completed: boolean;
tagIds: string[];
updatedAt: number;
syncStatus: 'pending' | 'synced' | 'conflict';
}
interface TodoStore {
todos: Todo[];
loadTodos: () => void;
createTodo: (title: string, tagIds?: string[]) => Todo;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
applyServerSync: (serverTodos: ServerTodo[]) => void;
}
export const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
loadTodos: () => {
const rows = db.getAllSync<any>(
'SELECT * FROM todos WHERE deleted = 0 ORDER BY updated_at DESC'
);
set({ todos: rows.map(parseDbTodo) });
},
createTodo: (title: string, tagIds: string[] = []) => {
const now = Date.now();
const todo: Todo = {
id: Crypto.randomUUID(),
title,
completed: false,
tagIds,
updatedAt: now,
syncStatus: 'pending',
};
// 1. Write to SQLite
db.runSync(
`INSERT INTO todos (id, title, completed, tag_ids, created_at, updated_at, sync_status)
VALUES (?, ?, 0, ?, ?, ?, 'pending')`,
[todo.id, todo.title, JSON.stringify(todo.tagIds), now, now],
);
// 2. Add to sync queue
db.runSync(
`INSERT INTO sync_queue (entity_type, entity_id, operation, payload, created_at)
VALUES ('todo', ?, 'create', ?, ?)`,
[todo.id, JSON.stringify(todo), now],
);
// 3. Update UI state immediately (optimistic)
set(state => ({ todos: [todo, ...state.todos] }));
return todo;
},
toggleTodo: (id: string) => {
const todo = get().todos.find(t => t.id === id);
if (!todo) return;
const now = Date.now();
const updated = { ...todo, completed: !todo.completed, updatedAt: now, syncStatus: 'pending' as const };
// Optimistic local update
db.runSync(
'UPDATE todos SET completed = ?, updated_at = ?, sync_status = ? WHERE id = ?',
[updated.completed ? 1 : 0, now, 'pending', id],
);
db.runSync(
`INSERT INTO sync_queue (entity_type, entity_id, operation, payload, created_at)
VALUES ('todo', ?, 'update', ?, ?)`,
[id, JSON.stringify({ id, completed: updated.completed, updated_at: now }), now],
);
set(state => ({
todos: state.todos.map(t => t.id === id ? updated : t),
}));
},
applyServerSync: (serverTodos: ServerTodo[]) => {
// Server sync response — apply changes, detect conflicts
const localTodos = new Map(get().todos.map(t => [t.id, t]));
const updates: Todo[] = [];
for (const serverTodo of serverTodos) {
const local = localTodos.get(serverTodo.id);
if (!local) {
// New from server — insert
const todo = parseServerTodo(serverTodo);
db.runSync(
`INSERT OR IGNORE INTO todos (id, title, completed, tag_ids, created_at, updated_at, synced_at, server_updated_at, sync_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'synced')`,
[todo.id, todo.title, todo.completed ? 1 : 0, JSON.stringify(todo.tagIds),
serverTodo.created_at, serverTodo.updated_at, Date.now(), serverTodo.updated_at],
);
updates.push(todo);
continue;
}
// Conflict: local has pending changes AND server has newer version
if (local.syncStatus === 'pending' && serverTodo.updated_at > (local as any).server_updated_at) {
// Strategy: server wins (can customize per field)
const resolved = { ...parseServerTodo(serverTodo), syncStatus: 'synced' as const };
db.runSync(
'UPDATE todos SET title = ?, completed = ?, sync_status = ?, server_updated_at = ?, synced_at = ? WHERE id = ?',
[resolved.title, resolved.completed ? 1 : 0, 'synced', serverTodo.updated_at, Date.now(), resolved.id],
);
updates.push(resolved);
} else if (local.syncStatus === 'pending') {
// Local is newer — keep local, mark synced after queue processes
continue;
} else {
// Clean update from server
const resolved = { ...parseServerTodo(serverTodo), syncStatus: 'synced' as const };
db.runSync(
'UPDATE todos SET title = ?, completed = ?, sync_status = ?, server_updated_at = ?, synced_at = ? WHERE id = ?',
[resolved.title, resolved.completed ? 1 : 0, 'synced', serverTodo.updated_at, Date.now(), resolved.id],
);
updates.push(resolved);
}
}
set(state => {
const updatesMap = new Map(updates.map(t => [t.id, t]));
return {
todos: state.todos.map(t => updatesMap.get(t.id) ?? t),
};
});
},
}));
Background Sync
// src/sync/sync-worker.ts
import NetInfo from '@react-native-community/netinfo';
import { AppState, AppStateStatus } from 'react-native';
import { db } from '../db/schema';
import { api } from '../api/client';
import { useTodoStore } from '../store/todos.store';
class SyncWorker {
private isSyncing = false;
initialize() {
// Sync on network reconnect
NetInfo.addEventListener(state => {
if (state.isConnected && !this.isSyncing) {
this.sync();
}
});
// Sync when app comes to foreground
AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (nextState === 'active') {
this.sync();
}
});
}
async sync() {
if (this.isSyncing) return;
const netState = await NetInfo.fetch();
if (!netState.isConnected) return;
this.isSyncing = true;
try {
await this.flushQueue();
await this.pullUpdates();
} finally {
this.isSyncing = false;
}
}
private async flushQueue() {
// Process pending mutations
const pending = db.getAllSync<any>(
'SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 50'
);
for (const item of pending) {
try {
const payload = JSON.parse(item.payload);
await api.post('/sync/mutations', {
entity_type: item.entity_type,
entity_id: item.entity_id,
operation: item.operation,
payload,
});
// Success — remove from queue, mark entity synced
db.runSync('DELETE FROM sync_queue WHERE id = ?', [item.id]);
db.runSync(
`UPDATE ${item.entity_type}s SET sync_status = 'synced', synced_at = ? WHERE id = ?`,
[Date.now(), item.entity_id],
);
} catch (error) {
// Track failures — exponential backoff based on attempt_count
db.runSync(
'UPDATE sync_queue SET attempt_count = attempt_count + 1, last_error = ? WHERE id = ?',
[(error as Error).message, item.id],
);
}
}
}
private async pullUpdates() {
// Get last sync timestamp
const result = db.getFirstSync<{ ts: number | null }>('SELECT MAX(synced_at) as ts FROM todos');
const since = result?.ts ?? 0;
const updates = await api.get<ServerTodo[]>(`/sync/todos?since=${since}`);
useTodoStore.getState().applyServerSync(updates);
}
}
export const syncWorker = new SyncWorker();
For React Native navigation patterns including deep linking, see the React Native navigation guide. For mobile performance optimization including FlatList and bundle size, see the mobile performance guide. The Claude Skills 360 bundle includes React Native skill sets covering offline-first patterns, sync strategies, and local database management. Start with the free tier to try mobile offline code generation.