DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building an Offline-First Mobile App with SQLite and Sync Queues

Building an Offline-First Mobile App with SQLite and Sync Queues

Building an Offline-First Mobile App with SQLite and Sync Queues

In this tutorial, you’ll learn how to design, implement, and test an offline-first mobile application. We’ll walk through a practical architecture that uses a local SQLite database for offline storage, a robust synchronization queue to reconcile changes with a remote server, and conflict-handling strategies to keep data consistent. The approach works well for iOS and Android apps built with a cross-platform framework like Kotlin/Java or React Native, and it’s adaptable to native Swift/Kotlin projects too.

Applied scenario

  • You’re building a note-taking app that must function without network connectivity.
  • Users can create, edit, and delete notes while offline.
  • When the device goes online, changes sync to the server in a predictable, conflict-aware manner.
  • You want deterministic behavior for data visibility, even when multiple clients edit the same item.

Outline

  • Architecture overview
  • Local data layer with SQLite
  • Sync queue design and state machine
  • Conflict resolution strategies
  • Server API contract and optimistic updates
  • Implementation: core components (data models, queue, sync engine)
  • Testing strategy (unit, integration, end-to-end)
  • Deployment considerations and tips
  • Example code snippets
  1. Architecture overview
  2. Local store: a SQLite database on the device stores all user data and a replicated schema for syncing metadata.
  3. Sync layer: a dedicated queue that batches changes locally and applies remote updates, with idempotent operations and retry/backoff.
  4. Conflict handling: deterministic rules decide how to merge concurrent edits (last-writer-wins, merged field-level changes, or server-driven resolution).
  5. Server API: REST or GraphQL endpoints that support create/update/delete with idempotent semantics and a “last modified” timestamp or version vector.
  6. Observability: lightweight event bus or reactive streams to propagate changes to the UI without blocking on network calls.

  7. Local data layer with SQLite

  8. Use a single source of truth on-device: a primary table for notes, plus a changes_log or sync_metadata table to track local mutations.

  9. Core tables:

    • notes(id TEXT PRIMARY KEY, content TEXT, title TEXT, updated_at DATETIME, deleted BOOLEAN)
    • changes_log(id TEXT PRIMARY KEY, operation TEXT, payload TEXT, created_at DATETIME, status TEXT)
  10. Why separate changes_log? It records intent, enabling reliable retries, offline queuing, and replay-safe synchronization.

  11. Sync queue design and state machine

  12. We implement a “sync agent” with a queue of work items. Each item represents a mutation to apply to the server.

  13. Item fields:

    • id (UUID)
    • type (insert/update/delete)
    • local_id (note id)
    • payload (JSON blob of the note data)
    • attempted_at (timestamp)
    • status (pending, in_progress, succeeded, failed)
    • remote_version (server version or last_updated_at)
  14. State machine:

    • pending -> in_progress when a network call starts
    • in_progress -> succeeded/failed depending on server response
    • failed -> backoff and retry (exponential)
  15. Idempotence: server endpoints are designed to be idempotent. Replaying the same change should not duplicate data.

  16. Conflict resolution strategies

  17. Field-level conflict policy:

    • If two clients edit the same note offline, you can merge by choosing the latest updated_at as the winner for each field, or implement a delta merge if both edited different fields.
  18. Operational conflict policy:

    • If a server-side edit occurs while you’re syncing your offline changes, you compare version tokens. If the server has a newer version, you fetch and merge, then retry local changes.
  19. User-facing conflict handling:

    • When conflicts are detected, present a resolution UI: compare versions, allow user to pick one, or auto-merge with a clear “conflict resolved by last edit” indicator.
  20. Server API contract and optimistic updates

  21. Endpoints (examples):

    • POST /notes with body {id, title, content, updated_at} to create
    • PATCH /notes/{id} with {title?, content?, updated_at}
    • DELETE /notes/{id}
    • GET /notes?since={timestamp} to fetch changes
  22. Optimistic updates:

    • Apply local changes immediately in the UI (and SQLite) with a temporary local version, then sync to the server.
    • If server rejects (e.g., conflict), roll back or merge based on the chosen strategy and notify the user if needed.
  23. Versioning:

    • Use optimistic locking with a version field or use updated_at timestamps for conflict detection.
  24. Implementation: core components

  25. Data models (pseudo-typed)

    • Note: id, title, content, updated_at, is_deleted
    • Change: id, type (insert/update/delete), note_id, payload, created_at, status
  26. SQLite helper

    • Initialize database with tables notes and changes_log
    • CRUD helpers with timestamps
  27. Sync engine

    • enqueueChange(type, note) creates a Change row and marks it as pending
    • syncLoop runs in background, processes pending changes in order
    • network layer handles REST calls with backoff
    • on success: mark Change as completed, update note with remote_version
    • on conflict: fetch remote note, merge locally, re-enqueue pending changes
  28. UI/data binding

    • Observe changes_log or notes table to refresh UI
    • Show offline status and synchronization state badge

Code snippets (illustrative, language-agnostic for clarity)

  • SQLite table creation (SQL) CREATE TABLE notes ( id TEXT PRIMARY KEY, title TEXT, content TEXT, updated_at DATETIME, is_deleted BOOLEAN DEFAULT 0 );

CREATE TABLE changes_log (
id TEXT PRIMARY KEY,
type TEXT, insert, update, delete
note_id TEXT,
payload TEXT,
created_at DATETIME,
status TEXT pending, in_progress, succeeded, failed
);

  • Enqueueing a change (pseudocode)
    function enqueueChange(type, note) {
    const change = {
    id: generateUuid(),
    type: type,
    note_id: note.id,
    payload: JSON.stringify(note),
    created_at: now(),
    status: 'pending'
    };
    db.insert('changes_log', change);
    return change.id;
    }

  • Sync loop (pseudocode)
    async function syncLoop() {
    while (true) {
    const item = db.queryOne("SELECT * FROM changes_log WHERE status='pending' ORDER BY created_at ASC LIMIT 1");
    if (!item) {
    await sleep(1000);
    continue;
    }
    db.update('changes_log', { id: item.id, status: 'in_progress' });
    try {
    const note = JSON.parse(item.payload);
    let response;
    if (item.type === 'insert' || item.type === 'update') {
    response = await api.patchOrPostNote(note);
    } else if (item.type === 'delete') {
    response = await api.deleteNote(note.id);
    }
    // On success
    db.update('changes_log', { id: item.id, status: 'succeeded' });
    // Optional: update local note version from response
    if (response && response.updated_note) {
    upsertNoteFromServer(response.updated_note);
    }
    } catch (err) {
    // Conflict or network error
    const backoff = computeBackoff(item.attempted_at);
    db.update('changes_log', { id: item.id, status: 'failed', attempted_at: now() });
    await sleep(backoff);
    // Re-enqueue automatically
    db.update('changes_log', { id: item.id, status: 'pending' });
    }
    }
    }

  • Conflict resolution hook
    async function resolveConflict(localNote, remoteNote) {
    // Simple strategy: field-wise last-write-wins
    const merged = {
    id: localNote.id,
    title: compareField(localNote.title, remoteNote.title),
    content: compareField(localNote.content, remoteNote.content),
    updated_at: max(localNote.updated_at, remoteNote.updated_at)
    };
    // Decide policy: auto-merge or prompt user
    return merged;
    }
    function compareField(a, b) {
    // if both edited, pick the one with later updated_at
    // placeholder: return the later of a or b
    return (new Date(a.updated_at) > new Date(b.updated_at)) ? a.value : b.value;
    }

Notes:

  • The code above is schematic. In a real app, implement robust type checks, error handling, and ensure JSON payloads are compact.
  • Use prepared statements for SQLite to prevent SQL injection and improve performance.
  • Consider using an event bus (e.g., RxJS or platform-native) to push changes to the UI when the notes table updates.
  1. Testing strategy
  2. Unit tests
    • Test enqueueChange creates log entries correctly
    • Test mergeConflict function behavior with crafted notes
  3. Integration tests
    • Mock server responses to exercise syncLoop: success, transient network failure, and server-side conflicts
    • Verify that on conflict, local changes are merged consistently
  4. End-to-end tests
    • Simulate offline and online transitions on a device or emulator
    • Validate UI reflects offline data, then updates after sync
  5. Data integrity tests

    • Ensure no data corruption when notes are edited offline and reloaded online
    • Validate idempotent replay of the same change
  6. Deployment considerations and tips

  7. Choose a lightweight local DB library that suits your stack and provides good migration support.

  8. Implement schema migrations carefully; never drop a table in a migration without preserving data.

  9. Use backoff and jitter for retries to avoid thundering herd problems on the server.

  10. Provide a clear user experience for conflicts: offer resolution options or automated non-destructive merges.

  11. Log sync metrics (queued items, success rate, average sync latency) to monitor reliability in production.

  12. Example project structure

  13. src/

    • db/
    • migrations/
    • schema.sql
    • sqliteClient.ts
    • models/
    • Note.ts
    • ChangeLog.ts
    • sync/
    • syncEngine.ts
    • conflictResolver.ts
    • api/
    • serverApi.ts
    • ui/
    • NoteList.tsx
    • NoteEditor.tsx
  14. tests/

    • unit/
    • integration/
    • e2e/
  15. Quick-start checklist

  16. Set up SQLite with notes and changes_log tables.

  17. Implement a sync engine that processes the queue with proper backoff.

  18. Create server endpoints that are idempotent and support optimistic locking.

  19. Implement conflict resolution and a simple UI for conflicts if needed.

  20. Add tests for offline scenarios and conflict cases.

  21. Instrument metrics and logging to observe sync behavior in the field.

If you’d like, I can tailor this tutorial to a specific framework (React Native, Flutter, native iOS/Android) and provide a concrete, runnable codebase outline with starter snippets for that stack. Would you prefer a React Native example with TypeScript, or a Flutter/D Dart example?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)