Zum Inhalt springen
Zomer Gregorio

Zomer Gregorio

Softwareentwickler

Lebenslauf
Sprache
← Blog

Offline-First Mobile Architecture: Handling SQLite Concurrency and Sync Conflicts

· Mobile Architecture · SQLite · Offline-First · TypeScript · State Management

Learn how to build resilient offline-first mobile applications by managing SQLite write serialization, background sync queues, and deterministic conflict resolution strategies.

Auf dem Laufenden bleiben

Erhalte eine kurze Nachricht, wenn ich etwas Neues veröffentliche. Deine E-Mail- oder Browserregistrierung wird nur für diese Updates gespeichert und kann jederzeit beendet werden. Ein Konto oder Trackingprofil ist nicht erforderlich.

Bevor Benachrichtigungen beginnen, ist eine Bestätigungs-E-Mail erforderlich.

Introduction to Offline-First Engineering

Designing a mobile application that functions reliably without an active network connection requires shifting from a remote-first paradigm to a local-first model. In an offline-first architecture, the local database—typically SQLite or a wrapper like WatermelonDB or Room—acts as the single source of truth for the UI. Remote servers become secondary synchronization targets rather than immediate gatekeepers for user actions.

This architectural shift introduces complex engineering challenges, primarily centered around local database concurrency, transaction isolation, background sync scheduling, and conflict resolution. When multiple threads, background workers, and UI components attempt to mutate local state simultaneously while network conditions fluctuate, naive implementations quickly succumb to database lock contention, UI blocking, and silent data corruption.

Managing SQLite Concurrency on Mobile

Mobile platforms run on constrained hardware with limited CPU cores and aggressive power management policies. SQLite operates via file locks. By default, SQLite supports a single writer and multiple concurrent readers using Write-Ahead Logging (WAL) mode. However, improper connection pooling or synchronous writes on the main thread will lead directly to application Not Responding (ANR) errors on Android or watchdog terminations on iOS.

Enabling WAL Mode and Single-Writer Queues

To maximize read concurrency without risking SQLITE_BUSY errors during heavy background synchronization, initialize your SQLite connection with WAL mode enabled. Furthermore, serialize all write operations through a dedicated asynchronous queue.

import SQLite from 'react-native-sqlite-storage';
 
export class DatabaseManager {
  private static instance: DatabaseManager;
  private db: SQLite.SQLiteDatabase | null = null;
  private writeQueue: Promise<void> = Promise.resolve();
 
  private constructor() {}
 
  public static getInstance(): DatabaseManager {
    if (!DatabaseManager.instance) {
      DatabaseManager.instance = new DatabaseManager();
    }
    return DatabaseManager.instance;
  }
 
  public async initialize(): Promise<void> {
    this.db = await SQLite.openDatabase({ name: 'app.db', location: 'default' });
    await this.db.executeSql('PRAGMA journal_mode = WAL;');
    await this.db.executeSql('PRAGMA synchronous = NORMAL;');
  }
 
  public enqueueWrite<T>(work: (tx: SQLite.Transaction) => Promise<T>): Promise<T> {
    this.writeQueue = this.writeQueue.then(async () => {
      if (!this.db) throw new Error('Database not initialized');
      return new Promise((resolve, reject) => {
        this.db.transaction(async (tx) => {
          try {
            const result = await work(tx);
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
      });
    });
    return this.writeQueue as Promise<T>;
  }
}

By serializing writes through a promise chain, you prevent race conditions between local user input processing and background sync payload ingestion. Reads, conversely, can execute concurrently against the WAL file without blocking incoming writes.

Architecting the Sync Engine

An effective offline-first sync engine decouples local mutations from remote persistence. Every local write should insert a record into an outbox or operation log table within the same transaction that updates the domain model. This ensures transactional integrity: an operation is never marked for synchronization unless its corresponding domain state change is safely committed locally.

The Outbox Pattern in SQLite

Consider an entity update. Instead of calling an API immediately, the application updates the entity and records an operation metadata entry.

async function updateEntityName(dbManager: DatabaseManager, entityId: string, newName: string): Promise<void> {
  await dbManager.enqueueWrite(async (tx) => {
    await tx.executeSql(
      'UPDATE entities SET name = ?, updated_at = ?, sync_status = ? WHERE id = ?;',
      [newName, Date.now(), 'pending', entityId]
    );
    
    await tx.executeSql(
      'INSERT INTO sync_outbox (id, entity_id, action, payload, created_at) VALUES (?, ?, ?, ?, ?);',
      [generateUUID(), entityId, 'UPDATE_ENTITY', JSON.stringify({ name: newName }), Date.now()]
    );
  });
}

A background worker periodically inspects the sync_outbox table, batches pending operations, and dispatches them to the backend API when connectivity is restored.

Deterministic Conflict Resolution Strategies

When two clients modify the same record offline, synchronization results in a conflict. Relying on server-wins or client-wins strategies frequently leads to silent data loss. Instead, mobile engineers must implement explicit conflict resolution policies based on business domain requirements.

Last-Write-Wins vs. Operational Transformation and CRDTs

  1. Last-Write-Wins (LWW): Relies on client-side or server-side physical timestamps. While simple to implement, LWW suffers from clock skew across distributed mobile devices. If a device has an inaccurate system clock, valid updates can be erroneously overwritten.
  2. Vector Clocks and Versioning: Attaches a monotonically increasing version number or vector clock to each entity row. If the server receives an update where the incoming version does not match the current server version, a conflict is flagged.
  3. Field-Level Merging: For multi-attribute forms, merge non-overlapping field modifications automatically while prompting the user only when conflicting edits occur on the exact same property.

Implementing Version-Checked Mutations

When sending outbox payloads to the backend, include the entity version read at the start of the local transaction.

interface SyncPayload {
  outboxId: string;
  entityId: string;
  version: number;
  changes: Record<string, any>;
}
 
async function processOutboxBatch(batch: SyncPayload[]): Promise<void> {
  for (const item of batch) {
    const response = await fetch(`https://api.example.com/entities/${item.entityId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ version: item.version, changes: item.changes }),
    });
 
    if (response.status === 409) {
      // Handle Conflict: Fetch server state, prompt user or auto-resolve
      await handleSyncConflict(item);
    } else if (response.ok) {
      // Remove item from local outbox
      await removeOutboxItem(item.outboxId);
    }
  }
}

Handling Edge Cases and Failure Modes

Offline-first architectures must gracefully handle scenarios that rarely occur in server-side environments.

App Termination During Synchronization

If the operating system kills the application while a batch of outbox items is mid-transfer, state can become desynchronized. To prevent this, ensure that network requests are idempotent. Assign unique idempotency keys to API requests so that retrying a failed or interrupted request does not duplicate resource creation on the server.

Storage Exhaustion

Mobile devices frequently run low on disk space. SQLite databases can grow unboundedly if old audit logs or soft-deleted records accumulate. Implement a retention policy that purges successfully synchronized outbox entries and archives historical read-models older than a defined threshold.

Performance Optimization and Verification

Benchmarking an offline-first mobile app requires testing under simulated poor network conditions (e.g., high latency, packet loss, and sudden drops) using tools like network link conditioners.

Profile database queries using EXPLAIN QUERY PLAN to ensure indices exist on foreign keys and frequently filtered columns like sync_status and updated_at. Without proper indexing, scanning thousands of outbox rows during background sync cycles will cause noticeable frame drops in the UI thread.

By combining WAL-mode concurrency, strict outbox transaction boundaries, and idempotent network synchronization, engineers can deliver robust offline-first experiences that withstand unpredictable mobile network topologies.