All articles
8 min read
October 23, 20268 min read

Offline-First Flutter Architecture: Drift SQLite, Reactive Streams & Outbox Sync

Build enterprise offline-first Flutter apps with local SQLite (Drift), reactive streams, background sync workers, and monotonic version vector conflict resolution.

Tags:#Flutter#SQLite#Drift#Offline First#Reactive Programming
Offline-First Flutter Architecture: Drift SQLite, Reactive Streams & Outbox Sync

In mobile applications deployed across variable network environments—such as logistics, field inspections, or healthcare—treating connectivity as a continuous prerequisite leads to fragile user experiences.

An offline-first architecture treats local persistence as the single source of truth for presentation, while network operations execute asynchronously through a persistent sync queue.

Offline Database Architecture
Offline Database Architecture

Local databases serve immediate UI renders; the sync engine synchronizes deltas in the background.

Core Principles of Offline-First

  1. Immediate Local Writes: User actions mutate local storage synchronously. The UI updates instantaneously with zero network latency.
  2. Persistent Outbox Pattern: Outgoing mutations are enqueued in an SQLite sync_queue table before any network attempt.
  3. Reactive Stream Binding: UI components subscribe directly to database table streams (via Drift/SQLite), never to raw HTTP responses.
  4. Idempotent Synchronization: Network sync endpoints accept UUIDs and monotonic version timestamps to guarantee safe retries.

Conflict Resolution Strategies

StrategyMechanismBest Suited ForRisk
Last-Write-Wins (LWW)Highest timestamp overwrites stateNon-critical status flags, user preferencesData loss on concurrent field edits
Two-Phase OutboxQueue commands, replay in sequenceOrder submission, payments, inventorySync stalls on corrupted payloads
Field-Level MergingCompute diffs per columnUser profiles, complex document formsIncreased sync payload complexity
Operational CRDTsConflict-Free Replicated Data TypesCollaborative notes, countersStorage overhead on mobile clients

Implementing Local Storage with Drift

Drift (formerly Moor) generates type-safe Dart APIs directly from relational SQLite schemas:

dart
import 'package:drift/drift.dart';

part 'app_database.g.dart';

// 1. Table Definitions
class Tasks extends Table {
  TextColumn get id => text()();
  TextColumn get title => text().withLength(min: 1, max: 200)();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  IntColumn get syncStatus => integer().withDefault(const Constant(0))(); // 0 = synced, 1 = pending

  @override
  Set<Column> get primaryKey => {id};
}

class SyncMutations extends Table {
  IntColumn get queueId => integer().autoIncrement()();
  TextColumn get entityId => text()();
  TextColumn get action => text()(); // INSERT, UPDATE, DELETE
  TextColumn get payloadJson => text()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

@DriftDatabase(tables: [Tasks, SyncMutations])
class AppDatabase extends _$AppDatabase {
  AppDatabase(QueryExecutor e) : super(e);

  @override
  int get schemaVersion => 1;

  // Reactive stream for UI observation
  Stream<List<Task>> watchAllTasks() {
    return (select(tasks)..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])).watch();
  }

  // Atomic offline write
  Future<void> createOfflineTask(String id, String title) {
    return transaction(() async {
      final now = DateTime.now();
      await into(tasks).insert(
        TasksCompanion.insert(
          id: id,
          title: title,
          updatedAt: Value(now),
          syncStatus: const Value(1), // Pending sync
        ),
      );
      await into(syncMutations).insert(
        SyncMutationsCompanion.insert(
          entityId: id,
          action: 'INSERT',
          payloadJson: '{"id": "$id", "title": "$title"}',
          createdAt: Value(now),
        ),
      );
    });
  }
}

Background Outbox Dispatcher

A dedicated sync coordinator monitors network connectivity and flushes the outbox queue:

dart
class SyncCoordinator {
  final AppDatabase db;
  final ApiClient api;
  bool _isSyncing = false;

  SyncCoordinator({required this.db, required this.api});

  Future<void> flushPendingMutations() async {
    if (_isSyncing) return;
    _isSyncing = true;

    try {
      final pending = await (db.select(db.syncMutations)
            ..orderBy([(m) => OrderingTerm.asc(m.queueId)]))
          .get();

      for (final mutation in pending) {
        final success = await api.dispatch(
          action: mutation.action,
          payload: mutation.payloadJson,
        );

        if (success) {
          // Delete from queue and mark local entity synced
          await (db.delete(db.syncMutations)..where((m) => m.queueId.equals(mutation.queueId))).go();
          await (db.update(db.tasks)..where((t) => t.id.equals(mutation.entityId)))
              .write(const TasksCompanion(syncStatus: Value(0)));
        } else {
          // Stop queue iteration on first network failure to preserve sequence
          break;
        }
      }
    } finally {
      _isSyncing = false;
    }
  }
}

Architectural Takeaways

By architecting around a local database and a command outbox queue, your Flutter app becomes resilient to subway tunnels, airplane modes, and spotty cellular networks.