Claude Code for Mobile Offline-First: Sync, Conflict Resolution, and Local Storage — Claude Skills 360 Blog
Blog / Mobile / Claude Code for Mobile Offline-First: Sync, Conflict Resolution, and Local Storage
Mobile

Claude Code for Mobile Offline-First: Sync, Conflict Resolution, and Local Storage

Published: August 23, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free