The Offline Consistency Problem

Most mobile apps claim "offline support," but few handle concurrent edits, partial sync failures, or multi-device conflicts gracefully. Traditional CRUD models with last-write-wins timestamps collapse under real-world network partitions. When a user edits a document offline on two devices, merges those changes after reconnection, then discovers half their work vanished—that's not a UX problem, it's an architecture problem.

Event sourcing offers a compelling alternative: store immutable events rather than mutable state. Instead of UPDATE users SET name = 'Alice', you append UserNameChanged(userId: 42, newName: 'Alice', timestamp: ...). Current state becomes a materialized view—a projection of the event log. This inversion unlocks offline sync, audit trails, time-travel debugging, and conflict resolution in a single architectural move.

CQRS Lite: Pragmatic Event Sourcing for Mobile

Full-blown CQRS (Command Query Responsibility Segregation) with separate read/write databases, event buses, and saga orchestrators is overkill for mobile clients. A "CQRS Lite" approach retains the core benefits while staying within SQLite and local storage constraints:

  • Event Store: A single append-only table with columns (eventId, aggregateId, eventType, payload, version, timestamp, deviceId). Payloads are JSON or protobuf blobs.
  • Projections: Denormalized read tables (e.g., users_view, cart_items_view) rebuilt by replaying events. These are ephemeral—deletable and reconstructible.
  • Command Handler: Validates business rules, appends events, triggers local projection updates. No network required.
  • Sync Engine: Pushes local events to server, pulls remote events, replays them into projections. Conflict resolution happens at event level, not row level.

This pattern fits naturally in Flutter (or React Native, Swift, Kotlin). A real-world e-commerce app might store CartItemAdded, CartItemRemoved, OrderPlaced events. The cart UI reads from cart_view, which is instantly consistent with the local event log. When the device reconnects, the sync engine pushes pending events and pulls server-side events (from other devices or backend processes), replays them, and the UI reflects the merged state—no manual conflict resolution code.

Implementing the Event Store in SQLite

SQLite is the backbone of mobile offline storage. A minimal event store schema:

CREATE TABLE events (
  event_id TEXT PRIMARY KEY,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload BLOB NOT NULL,
  version INTEGER NOT NULL,
  timestamp INTEGER NOT NULL,
  device_id TEXT NOT NULL,
  synced INTEGER DEFAULT 0
);
CREATE INDEX idx_aggregate ON events(aggregate_id, version);
CREATE INDEX idx_unsynced ON events(synced) WHERE synced = 0;

The version column enforces optimistic concurrency within an aggregate (e.g., a shopping cart). Before appending CartItemAdded, the command handler reads the current version from the last event for that aggregate_id, increments it, and writes. If another process (or synced event) already wrote version N, the insert fails, forcing a retry with conflict resolution.

The synced flag marks events pending upload. A background isolate (Dart) or coroutine (Kotlin) batch-uploads unsynced events every 5-10 seconds when online, using exponential backoff on failure. Server responses include a global sequence number, enabling deterministic replay order across devices.

Projection Rebuilding and Incremental Updates

Replaying thousands of events on every app launch is impractical. Instead, maintain a projection_checkpoint table:

CREATE TABLE projection_checkpoint (
  projection_name TEXT PRIMARY KEY,
  last_event_id TEXT NOT NULL,
  last_timestamp INTEGER NOT NULL
);

On startup, each projection (e.g., cart_view) reads its checkpoint, queries SELECT * FROM events WHERE timestamp > ? ORDER BY timestamp, and applies only new events. For a 10,000-event log, this might mean processing 5-50 events per launch—negligible overhead.

Projection logic is pure: given an event and current state, produce new state. Example in Dart:

class CartProjection {
  void apply(Event event, SQLiteDatabase db) {
    switch (event.type) {
      case 'CartItemAdded':
        final payload = jsonDecode(event.payload);
        db.execute(
          'INSERT OR REPLACE INTO cart_view (product_id, quantity) VALUES (?, ?)',
          [payload['productId'], payload['quantity']]
        );
        break;
      case 'CartItemRemoved':
        db.execute('DELETE FROM cart_view WHERE product_id = ?', [payload['productId']]);
        break;
    }
  }
}

This idempotent logic means replaying the same event twice is safe—critical when network retries or sync conflicts cause duplicate deliveries.

Conflict Resolution: Operational Transformation Lite

When two devices generate conflicting events (e.g., both add the same product to a cart), the event log preserves both. Conflict resolution happens during projection. Common strategies:

  • Last-write-wins by timestamp: Simple but loses data. Acceptable for non-critical fields (e.g., user preferences).
  • Merge semantics: For counters or sets, union operations. If Device A adds item X and Device B adds item Y, the projection includes both.
  • Business rules: If a cart has a max quantity, the projection enforces it during replay, capping the sum from all devices.

For text editing (a harder problem), Operational Transformation or CRDTs (Conflict-Free Replicated Data Types) are better fits. Event sourcing doesn't solve OT/CRDT—it provides the infrastructure to apply them. A notes app might store CharacterInserted(position, char) events and use a CRDT projection to merge concurrent edits.

Sync Protocol: Event Replication Over HTTP

The sync engine is a stateless HTTP client. Every 10 seconds (or on user action), it:

  1. Fetches unsynced local events: SELECT * FROM events WHERE synced = 0.
  2. POSTs them to /api/events/batch with device ID and last-known server sequence number.
  3. Server validates, appends to its event log (PostgreSQL, Firestore, etc.), returns new global sequence numbers.
  4. Client marks events as synced: UPDATE events SET synced = 1 WHERE event_id IN (...).
  5. GETs /api/events?since={lastSequence} to pull remote events.
  6. Appends remote events to local store (with synced = 1), triggers projection replay.

Bandwidth is proportional to event count, not data size. A 10KB product catalog update becomes a single CatalogUpdated event with a diff payload. Clients that already have the catalog skip re-downloading.

Authentication uses short-lived JWTs refreshed on each sync. If refresh fails (expired session), the app prompts re-login but retains local events—no data loss.

Testing and Debugging: Time-Travel and Replay

Event sourcing's killer feature for development: deterministic replay. To reproduce a bug, export the event log from the user's device (via support tooling), replay it locally, and step through projection logic. No need to recreate UI interactions—events are the interactions.

Integration tests become trivial:

test('cart calculates total correctly after concurrent adds', () {
  final store = EventStore();
  store.append(CartItemAdded(productId: 'A', quantity: 2, price: 10));
  store.append(CartItemAdded(productId: 'B', quantity: 1, price: 15));
  final projection = CartProjection();
  projection.rebuild(store);
  expect(projection.total, equals(35));
});

Time-travel debugging: add a slider to the dev UI that replays events up to a specific timestamp, letting you scrub through app state like a video.

Performance: Benchmarks from Production

In a Flutter e-commerce app with 5,000 events per user (3 months of activity), cold-start projection rebuild took 180ms on a mid-range Android device (Snapdragon 720G). Incremental updates (5-10 events) took