Steven's Knowledge

Offline-First

Flutter offline-first — local databases, sync engines, conflict resolution, optimistic updates

Offline-First

Mobile apps that break when the network drops are broken apps. Users open your app in elevators, subways, airplanes, and rural areas with no signal. An offline-first architecture treats the local database as the source of truth and the network as a background synchronization channel — not a prerequisite for every operation.

Why Offline-First

The network is unreliable by default

Desktop web developers can often assume a stable connection. Mobile developers cannot. The typical mobile session crosses multiple network conditions: Wi-Fi to cellular handoff, dead zones in parking garages, bandwidth throttling in crowded venues. Designing for a reliable network and bolting on "offline support" later leads to fragile error handling, lost user data, and confusing loading spinners.

Architecture choices that enable vs block offline

ApproachOffline BehaviorUser Experience
Network-first, no local cacheApp is unusable without signalBroken
Network-first, cache as fallbackReads work offline, writes failFrustrating
Local-first read, network writeReads always work, writes queueAcceptable
Local-first read and writeEverything works, syncs laterSeamless

The last row is what this page describes. The key architectural decision is: write to local storage first, always, and treat server sync as an eventually-consistent background process.

Local Database Selection

Choosing the right local database is a foundational decision. The wrong pick can block you from features you need later (full-text search, encryption, reactive streams).

Featuredrift (SQLite)isarobjectboxhiveshared_preferences
Query capabilityFull SQL, joins, aggregatesFilters, links, full-textFilters, relationsKey-value onlyKey-value only
Type safetyCode-generated, compile-timeCode-generatedCode-generatedManual castingManual casting
Performance (large datasets)ExcellentExcellentExcellentDegradesNot designed for it
Reactive streamsBuilt-in watch()Built-in watch()Built-in streamslistenable()None
EncryptionSQLCipher supportBuilt-inPlannedAES via hive_flutterNone
Schema migrationVersioned, SQL-level controlAutomaticAutomaticManualN/A
Web supportYes (sql.js)NoNoYesYes
Package maturityStable, actively maintainedMaintainedStableMaintenance modeOfficial Flutter team

Decision tree

Need complex queries (joins, aggregates, GROUP BY)?
├── Yes → drift
└── No
    ├── Need cross-platform including web?
    │   ├── Yes → drift or hive
    │   └── No → objectbox (fastest raw throughput) or isar
    └── Just key-value pairs?
        └── shared_preferences (tiny data) or hive (structured)

drift is the safe default. SQLite is battle-tested across billions of devices, and drift wraps it with type-safe Dart code generation, reactive streams, and full migration control. Unless you have a specific reason to avoid SQL, start with drift.

Drift setup with type-safe queries and reactive streams

import 'package:drift/drift.dart';

part 'database.g.dart';

// 1. Define tables
class Orders extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 200)();
  RealColumn get total => real()();
  IntColumn get status => intEnum<OrderStatus>()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  TextColumn get syncStatus => textEnum<SyncStatus>().withDefault(
    Constant(SyncStatus.pending.name),
  )();
}

enum OrderStatus { draft, confirmed, shipped, delivered }
enum SyncStatus { pending, syncing, synced, failed }

// 2. Define the database
@DriftDatabase(tables: [Orders])
class AppDatabase extends _$AppDatabase {
  AppDatabase(QueryExecutor e) : super(e);

  @override
  int get schemaVersion => 1;

  // Type-safe query: all orders with a given status
  Future<List<Order>> ordersByStatus(OrderStatus status) {
    return (select(orders)
      ..where((o) => o.status.equalsValue(status))
      ..orderBy([(o) => OrderingTerm.desc(o.createdAt)])
    ).get();
  }

  // Reactive stream: emits new list whenever the table changes
  Stream<List<Order>> watchPendingSync() {
    return (select(orders)
      ..where((o) => o.syncStatus.equalsValue(SyncStatus.pending))
    ).watch();
  }

  // Upsert with sync tracking
  Future<void> upsertOrder(OrdersCompanion entry) {
    return into(orders).insertOnConflictUpdate(entry);
  }
}

Offline-First Architecture Patterns

Local-first read

Always read from the local database. Trigger a background sync to refresh data from the server, but never block the UI waiting for a network response.

class OrderRepository {
  final AppDatabase _db;
  final OrderApi _api;
  final ConnectivityService _connectivity;

  OrderRepository(this._db, this._api, this._connectivity);

  // Always returns local data immediately
  Stream<List<Order>> watchOrders() => _db.watchAllOrders();

  // Background refresh — does not block callers
  Future<void> refreshFromServer() async {
    if (!await _connectivity.isOnline) return;
    try {
      final remote = await _api.fetchOrders();
      for (final order in remote) {
        await _db.upsertOrder(order.toCompanion());
      }
    } catch (e) {
      // Log but do not throw — local data is still valid
      debugPrint('Background refresh failed: $e');
    }
  }
}

Write-through

Write to the local database first, mark the record as pending sync, and enqueue for server synchronization. The user sees the change immediately.

Future<void> createOrder(Order order) async {
  // 1. Write locally with pending sync status
  await _db.upsertOrder(
    OrdersCompanion(
      id: Value(order.id),
      title: Value(order.title),
      total: Value(order.total),
      status: Value(order.status),
      syncStatus: const Value(SyncStatus.pending),
    ),
  );

  // 2. Enqueue for background sync
  await _syncQueue.enqueue(SyncOperation(
    type: SyncOpType.create,
    table: 'orders',
    recordId: order.id.toString(),
    payload: order.toJson(),
  ));
}

Connectivity awareness

Use connectivity_plus to detect network state and trigger sync when connectivity returns.

import 'package:connectivity_plus/connectivity_plus.dart';

class ConnectivityService {
  final Connectivity _connectivity = Connectivity();

  Stream<bool> get onlineStream =>
      _connectivity.onConnectivityChanged.map(
        (results) => results.any((r) => r != ConnectivityResult.none),
      );

  Future<bool> get isOnline async {
    final results = await _connectivity.checkConnectivity();
    return results.any((r) => r != ConnectivityResult.none);
  }
}

// In your sync service initialization
connectivityService.onlineStream.listen((online) {
  if (online) {
    syncEngine.processPendingQueue();
  }
});

connectivity_plus reports whether a network interface is available, not whether the server is reachable. A device can have Wi-Fi but no internet (captive portals, DNS failures). Always handle network errors during actual sync attempts regardless of connectivity status.

Queue-based sync

A durable pending operations queue is the backbone of offline write support.

class SyncQueue {
  final AppDatabase _db;

  Future<void> enqueue(SyncOperation op) async {
    await _db.insertSyncOperation(SyncOperationsCompanion(
      id: Value(const Uuid().v4()),
      type: Value(op.type.name),
      table: Value(op.table),
      recordId: Value(op.recordId),
      payload: Value(jsonEncode(op.payload)),
      createdAt: Value(DateTime.now()),
      retryCount: const Value(0),
      status: const Value('pending'),
    ));
  }

  Future<List<SyncOperation>> pendingOperations() {
    return _db.getPendingSyncOps();
  }

  Future<void> markCompleted(String opId) {
    return _db.updateSyncOpStatus(opId, 'completed');
  }

  Future<void> markFailed(String opId, String error) {
    return _db.updateSyncOpFailed(opId, error);
  }
}

Optimistic Updates

Optimistic updates show the result of an action immediately, before the server confirms it. If the server rejects the change, the UI rolls back.

Pattern with Riverpod

@riverpod
class OrderList extends _$OrderList {
  @override
  Future<List<Order>> build() async {
    final db = ref.read(databaseProvider);
    return db.watchAllOrders().first;
  }

  Future<void> updateOrderTitle(int orderId, String newTitle) async {
    final previousState = state.valueOrNull ?? [];

    // 1. Optimistically update local state
    state = AsyncData(
      previousState.map((o) {
        return o.id == orderId ? o.copyWith(title: newTitle) : o;
      }).toList(),
    );

    try {
      // 2. Persist locally
      await ref.read(databaseProvider).updateOrderTitle(orderId, newTitle);

      // 3. Attempt server sync
      await ref.read(orderApiProvider).updateOrder(orderId, title: newTitle);
    } catch (e) {
      // 4. Rollback on failure
      state = AsyncData(previousState);
      ref.read(snackbarProvider).show('Update failed. Change reverted.');
    }
  }
}

Pattern with Bloc

class OrderBloc extends Bloc<OrderEvent, OrderState> {
  final OrderRepository _repo;

  OrderBloc(this._repo) : super(const OrderState.initial()) {
    on<UpdateOrderTitle>(_onUpdateTitle);
  }

  Future<void> _onUpdateTitle(
    UpdateOrderTitle event,
    Emitter<OrderState> emit,
  ) async {
    final previous = state.orders;

    // Optimistic update
    final updated = state.orders.map((o) {
      return o.id == event.orderId ? o.copyWith(title: event.newTitle) : o;
    }).toList();
    emit(state.copyWith(orders: updated));

    try {
      await _repo.updateOrderTitle(event.orderId, event.newTitle);
    } catch (e) {
      // Rollback
      emit(state.copyWith(
        orders: previous,
        error: 'Failed to update order. Reverted.',
      ));
    }
  }
}

Conflict Resolution

When multiple clients (or a client and a server) modify the same record while offline, you have a conflict. There is no universal solution — the right strategy depends on your domain.

Strategies overview

StrategyData Loss RiskComplexityBest For
Last-write-wins (LWW)PossibleLowSettings, preferences, non-critical data
Server-winsClient changes lostLowAuthoritative server data (prices, inventory)
Client-winsServer changes lostLowUser-generated drafts
Field-level mergeMinimalMediumRecords with independent fields
Vector clocksNone (with manual resolution)HighMulti-device sync
CRDTsNone (automatic)Very highReal-time collaboration

Last-write-wins with timestamps

The simplest approach. Each record carries an updatedAt timestamp; the most recent write wins.

class ConflictResolver {
  /// Returns the winning version based on timestamp comparison.
  Order resolveOrder(Order local, Order remote) {
    if (local.updatedAt.isAfter(remote.updatedAt)) {
      return local;
    }
    return remote;
  }
}

LWW requires synchronized clocks. If the client clock is wrong (common on mobile), you will silently lose data. Consider using server-issued timestamps instead of client timestamps, or hybrid logical clocks.

Field-level merge

Instead of choosing one entire record, merge individual fields. This preserves changes from both sides when they modified different fields.

class FieldLevelMerger {
  /// Merges local and remote orders against a common ancestor (base).
  /// Fields changed only on one side take that value.
  /// Fields changed on both sides use the remote value (server-wins for conflicts).
  Order mergeOrder(Order base, Order local, Order remote) {
    return Order(
      id: base.id,
      title: local.title != base.title && remote.title == base.title
          ? local.title
          : remote.title,
      total: local.total != base.total && remote.total == base.total
          ? local.total
          : remote.total,
      status: local.status != base.status && remote.status == base.status
          ? local.status
          : remote.status,
      updatedAt: remote.updatedAt,
    );
  }
}

Vector clocks for ordering

When you need to detect true conflicts (concurrent edits) rather than just comparing timestamps, vector clocks track the causal order of events across multiple clients.

class VectorClock {
  final Map<String, int> _clock;

  VectorClock([Map<String, int>? initial]) : _clock = Map.from(initial ?? {});

  void increment(String nodeId) {
    _clock[nodeId] = (_clock[nodeId] ?? 0) + 1;
  }

  /// Returns true if this clock is causally before [other].
  bool isBefore(VectorClock other) {
    for (final key in _clock.keys) {
      if ((_clock[key] ?? 0) > (other._clock[key] ?? 0)) return false;
    }
    return _clock.entries.any(
      (e) => e.value < (other._clock[e.key] ?? 0),
    );
  }

  /// Returns true if neither clock is before the other — a true conflict.
  bool isConcurrentWith(VectorClock other) {
    return !isBefore(other) && !other.isBefore(this);
  }

  VectorClock merge(VectorClock other) {
    final merged = Map<String, int>.from(_clock);
    for (final entry in other._clock.entries) {
      merged[entry.key] = max(merged[entry.key] ?? 0, entry.value);
    }
    return VectorClock(merged);
  }
}

CRDT basics

Conflict-free Replicated Data Types (CRDTs) guarantee that concurrent operations converge to the same state without coordination. In Flutter, you rarely build CRDTs from scratch — use packages like crdt or server-side solutions (e.g., Yjs, Automerge). CRDTs shine for real-time collaborative editing but add significant complexity for typical CRUD apps.

Sync Engine Design

Full sync vs incremental sync

ApproachData TransferServer ComplexitySuitable For
Full syncRe-downloads everythingLowSmall datasets (<1000 records)
Incremental (delta) syncOnly changed recordsMediumLarge datasets, frequent updates

Always prefer incremental sync for production apps. Full sync wastes bandwidth and battery.

Incremental sync with cursor

Track a lastSyncedAt cursor; only fetch records modified after that timestamp.

class SyncEngine {
  final AppDatabase _db;
  final OrderApi _api;
  final SyncQueue _queue;

  SyncEngine(this._db, this._api, this._queue);

  /// Pull: fetch changes from server since last sync
  Future<void> pullChanges() async {
    final cursor = await _db.getLastSyncCursor('orders');

    var page = 0;
    bool hasMore = true;

    while (hasMore) {
      final response = await _api.fetchOrdersSince(
        since: cursor,
        page: page,
        pageSize: 100,
      );

      for (final remote in response.orders) {
        final local = await _db.getOrderById(remote.id);
        if (local == null) {
          await _db.upsertOrder(remote.toCompanion(synced: true));
        } else {
          final resolved = _conflictResolver.resolveOrder(local, remote);
          await _db.upsertOrder(resolved.toCompanion(synced: true));
        }
      }

      hasMore = response.hasMore;
      page++;
    }

    await _db.updateSyncCursor('orders', DateTime.now());
  }

  /// Push: send pending local changes to server
  Future<void> pushChanges() async {
    final pending = await _queue.pendingOperations();

    for (final op in pending) {
      try {
        await _queue.markStatus(op.id, 'syncing');
        await _api.sendOperation(op);
        await _queue.markCompleted(op.id);
        await _db.updateSyncStatus(op.recordId, SyncStatus.synced);
      } catch (e) {
        await _queue.markFailed(op.id, e.toString());
        await _retryScheduler.scheduleRetry(op);
      }
    }
  }

  /// Full sync cycle: push first, then pull
  Future<void> sync() async {
    await pushChanges();  // push first to reduce conflicts
    await pullChanges();
  }
}

Idempotent operations

APIs must be safe to retry. If a push fails mid-flight, the client will retry — the server must not create duplicates.

// Client: include a client-generated idempotency key
Future<void> sendOperation(SyncOperation op) async {
  await _dio.post(
    '/api/orders',
    data: op.payload,
    options: Options(headers: {
      'Idempotency-Key': op.id,  // UUID generated at enqueue time
    }),
  );
}

The server stores the idempotency key and returns the cached response if it sees the same key again.

Sync status tracking

Every record needs a sync status visible to the UI so users understand what is local-only vs confirmed.

enum SyncStatus { pending, syncing, synced, failed }

// In the UI
Widget buildOrderTile(Order order) {
  return ListTile(
    title: Text(order.title),
    trailing: switch (order.syncStatus) {
      SyncStatus.pending => const Icon(Icons.cloud_queue, color: Colors.orange),
      SyncStatus.syncing => const SizedBox(
          width: 16, height: 16,
          child: CircularProgressIndicator(strokeWidth: 2),
        ),
      SyncStatus.synced  => const Icon(Icons.cloud_done, color: Colors.green),
      SyncStatus.failed  => const Icon(Icons.cloud_off, color: Colors.red),
    },
  );
}

Background Sync

workmanager setup

The workmanager package wraps Android WorkManager and iOS BGTaskScheduler for periodic background execution.

import 'package:workmanager/workmanager.dart';

const syncTaskName = 'com.app.backgroundSync';

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    // This runs in a separate Isolate — you must re-initialize dependencies
    final db = await openDatabase();
    final api = OrderApi(Dio());
    final queue = SyncQueue(db);
    final engine = SyncEngine(db, api, queue);

    try {
      await engine.sync();
      return true;  // success
    } catch (e) {
      debugPrint('Background sync failed: $e');
      return false; // retry according to backoff policy
    }
  });
}

Future<void> initBackgroundSync() async {
  await Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
  await Workmanager().registerPeriodicTask(
    'sync-periodic',
    syncTaskName,
    frequency: const Duration(minutes: 15),
    constraints: Constraints(
      networkType: NetworkType.connected,
      requiresBatteryNotLow: true,
    ),
    backoffPolicy: BackoffPolicy.exponential,
    backoffPolicyDelay: const Duration(minutes: 1),
  );
}

Platform limitations

PlatformConstraint
AndroidWorkManager respects Doze mode; minimum interval is 15 minutes; exact timing is not guaranteed
iOSBGTaskScheduler is advisory — the OS decides when to run your task; budget is limited to ~30 seconds of execution time
BothThe callback runs in a fresh Isolate with no access to the main Isolate's state; you must reinitialize everything

On iOS, background fetch is not reliable for time-sensitive sync. If immediate sync is critical, consider push notifications (APNs silent push) to wake the app and trigger a sync cycle.

Handling app termination during sync

If the OS kills your app mid-sync, partial writes can leave the database in an inconsistent state. Wrap sync batches in database transactions.

Future<void> pullChangesTransactional(List<Order> batch) async {
  await _db.transaction(() async {
    for (final order in batch) {
      await _db.upsertOrder(order.toCompanion());
    }
    await _db.updateSyncCursor('orders', batch.last.updatedAt);
  });
}

If the transaction is interrupted, SQLite rolls back automatically — no partial state.

Error Handling and Retry

Exponential backoff

Failed sync operations should not hammer the server. Use exponential backoff with jitter.

class RetryScheduler {
  static const _maxRetries = 8;
  static const _baseDelay = Duration(seconds: 1);

  Duration delayForAttempt(int attempt) {
    final exponential = _baseDelay * pow(2, attempt).toInt();
    final capped = exponential > const Duration(minutes: 5)
        ? const Duration(minutes: 5)
        : exponential;
    // Add jitter: +/- 25%
    final jitter = capped * (0.75 + Random().nextDouble() * 0.5);
    return jitter;
  }

  Future<void> scheduleRetry(SyncOperation op) async {
    if (op.retryCount >= _maxRetries) {
      await _deadLetterQueue.add(op);
      return;
    }
    final delay = delayForAttempt(op.retryCount);
    Future.delayed(delay, () => _syncEngine.retrySingleOp(op));
  }
}

Dead letter queue

Operations that exceed the retry limit go to a dead letter queue. The app should surface these to the user or to monitoring.

class DeadLetterQueue {
  final AppDatabase _db;

  Future<void> add(SyncOperation op) async {
    await _db.insertDeadLetter(DeadLetterCompanion(
      operationId: Value(op.id),
      table: Value(op.table),
      recordId: Value(op.recordId),
      payload: Value(jsonEncode(op.payload)),
      error: Value(op.lastError ?? 'Max retries exceeded'),
      failedAt: Value(DateTime.now()),
    ));
    await _db.deleteSyncOperation(op.id);
  }

  Stream<List<DeadLetter>> watchFailed() => _db.watchDeadLetters();
}

Conflict resolution UI

When automatic resolution is not appropriate, let the user choose.

Future<void> showConflictDialog(
  BuildContext context,
  Order localVersion,
  Order serverVersion,
) async {
  final choice = await showDialog<ConflictChoice>(
    context: context,
    barrierDismissible: false,
    builder: (_) => AlertDialog(
      title: const Text('Sync Conflict'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text('This order was modified on another device.'),
          const SizedBox(height: 16),
          _buildComparisonTable(localVersion, serverVersion),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context, ConflictChoice.keepLocal),
          child: const Text('Keep My Version'),
        ),
        TextButton(
          onPressed: () => Navigator.pop(context, ConflictChoice.keepRemote),
          child: const Text('Use Server Version'),
        ),
      ],
    ),
  );

  if (choice == ConflictChoice.keepLocal) {
    await _forceUpload(localVersion);
  } else {
    await _db.upsertOrder(serverVersion.toCompanion());
  }
}

Data Migration

Schema versioning in drift

Drift supports versioned schema migrations with full SQL control.

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

  @override
  int get schemaVersion => 3;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) => m.createAll(),
    onUpgrade: (m, from, to) async {
      if (from < 2) {
        // v1 → v2: add syncStatus column
        await m.addColumn(orders, orders.syncStatus);
      }
      if (from < 3) {
        // v2 → v3: add index on updatedAt for sync queries
        await customStatement(
          'CREATE INDEX idx_orders_updated ON orders(updated_at)',
        );
      }
    },
    beforeOpen: (details) async {
      // Enable WAL mode for better concurrent read/write
      await customStatement('PRAGMA journal_mode=WAL');
      await customStatement('PRAGMA foreign_keys=ON');
    },
  );
}

Migration strategies

StrategyWhen to UseRisk
Additive (add column, add table)New features, no data lossLow
Transform (rename, change type)Refactoring schemaMedium — requires data copy
Destructive (drop table, drop column)Removing featuresHigh — must migrate data first

Handling migration failures

onUpgrade: (m, from, to) async {
  try {
    if (from < 2) {
      await m.addColumn(orders, orders.syncStatus);
    }
  } catch (e) {
    // Log the failure and report to crash analytics
    debugPrint('Migration from v$from to v$to failed: $e');

    // Fallback: recreate the database (last resort — loses data)
    // Only do this if the data can be re-synced from the server
    await m.createAll();
  }
},

Never silently drop and recreate the database in production unless all data can be re-fetched from the server. For truly local data (drafts, preferences), a migration failure that destroys data is a data-loss bug.

Testing Offline Scenarios

Mock connectivity

class MockConnectivityService implements ConnectivityService {
  bool _online;
  final _controller = StreamController<bool>.broadcast();

  MockConnectivityService({bool online = true}) : _online = online;

  @override
  Stream<bool> get onlineStream => _controller.stream;

  @override
  Future<bool> get isOnline async => _online;

  void setOnline(bool value) {
    _online = value;
    _controller.add(value);
  }
}

test('enqueues writes when offline', () async {
  final connectivity = MockConnectivityService(online: false);
  final db = await openTestDatabase();
  final repo = OrderRepository(db, MockOrderApi(), connectivity);

  await repo.createOrder(testOrder);

  final pending = await db.getPendingSyncOps();
  expect(pending, hasLength(1));
  expect(pending.first.recordId, testOrder.id.toString());
});

Testing sync logic without a real server

class MockOrderApi implements OrderApi {
  final List<Order> _serverOrders = [];
  bool shouldFail = false;

  @override
  Future<SyncResponse> fetchOrdersSince({DateTime? since, int page = 0, int pageSize = 100}) async {
    if (shouldFail) throw DioException(requestOptions: RequestOptions());
    final filtered = since != null
        ? _serverOrders.where((o) => o.updatedAt.isAfter(since)).toList()
        : _serverOrders;
    return SyncResponse(orders: filtered, hasMore: false);
  }

  @override
  Future<void> sendOperation(SyncOperation op) async {
    if (shouldFail) throw DioException(requestOptions: RequestOptions());
    _serverOrders.add(Order.fromJson(op.payload));
  }

  void seedServerData(List<Order> orders) => _serverOrders.addAll(orders);
}

test('sync engine pulls and merges remote changes', () async {
  final db = await openTestDatabase();
  final api = MockOrderApi();
  final engine = SyncEngine(db, api, SyncQueue(db));

  // Seed server with an order
  api.seedServerData([Order(id: 1, title: 'Remote Order', updatedAt: DateTime.now())]);

  await engine.pullChanges();

  final local = await db.getOrderById(1);
  expect(local, isNotNull);
  expect(local!.title, 'Remote Order');
});

Testing conflict resolution

test('field-level merge preserves non-conflicting changes', () {
  final base   = Order(id: 1, title: 'Original',  total: 100.0, status: OrderStatus.draft);
  final local  = Order(id: 1, title: 'Local Edit', total: 100.0, status: OrderStatus.draft);
  final remote = Order(id: 1, title: 'Original',   total: 150.0, status: OrderStatus.confirmed);

  final merger = FieldLevelMerger();
  final result = merger.mergeOrder(base, local, remote);

  expect(result.title, 'Local Edit');         // changed locally only
  expect(result.total, 150.0);                // changed remotely only
  expect(result.status, OrderStatus.confirmed); // changed remotely only
});

Integration testing with real SQLite

import 'package:drift/native.dart';

AppDatabase openTestDatabase() {
  return AppDatabase(NativeDatabase.memory());
}

test('full offline cycle: create, go offline, queue, go online, sync', () async {
  final db = openTestDatabase();
  final api = MockOrderApi();
  final connectivity = MockConnectivityService(online: true);
  final queue = SyncQueue(db);
  final repo = OrderRepository(db, api, connectivity);
  final engine = SyncEngine(db, api, queue);

  // Create an order while online
  await repo.createOrder(Order(id: 1, title: 'Test', total: 50.0));
  expect(await queue.pendingOperations(), hasLength(1));

  // Sync pushes it to the server
  await engine.pushChanges();
  expect(await queue.pendingOperations(), isEmpty);

  // Go offline, create another order
  connectivity.setOnline(false);
  await repo.createOrder(Order(id: 2, title: 'Offline Order', total: 75.0));
  expect(await queue.pendingOperations(), hasLength(1));

  // Come back online, sync again
  connectivity.setOnline(true);
  await engine.pushChanges();
  expect(await queue.pendingOperations(), isEmpty);
});

Anti-Patterns

1. Treating local storage as just a cache

If you clear the "cache" on low storage, you delete the user's unsynced data. Local storage in an offline-first app is not a cache — it is the primary data store. Never evict unsynced records.

// Anti-pattern: clearing everything
Future<void> clearCache() async {
  await _db.deleteAll();  // Destroys unsynced user data
}

// Correct: only clear synced data
Future<void> clearSyncedData() async {
  await (_db.delete(_db.orders)
    ..where((o) => o.syncStatus.equalsValue(SyncStatus.synced))
  ).go();
}

2. Synchronous network checks before every operation

// Anti-pattern: blocking the UI on a network check
Future<Order?> getOrder(int id) async {
  if (await isOnline()) {          // blocks, flaky, slow
    return await api.fetchOrder(id);
  }
  return await db.getOrder(id);
}

// Correct: always read local, sync in background
Stream<Order?> watchOrder(int id) => db.watchOrderById(id);

3. Unbounded sync queues

If the user creates thousands of records offline, an unbounded queue consumes memory and takes forever to drain. Apply backpressure.

// Process in batches, not all at once
Future<void> pushChanges({int batchSize = 50}) async {
  while (true) {
    final batch = await _queue.pendingOperations(limit: batchSize);
    if (batch.isEmpty) break;
    for (final op in batch) {
      await _processSingleOp(op);
    }
  }
}

4. Ignoring partial sync failures

If a batch of 100 operations partially fails (50 succeed, 50 fail), you must not retry the successful ones. Track each operation individually.

// Anti-pattern: retry the whole batch
Future<void> syncBatch(List<SyncOperation> ops) async {
  try {
    await api.sendBatch(ops);         // if this fails at op 51...
    await markAllCompleted(ops);      // ...none get marked
  } catch (e) {
    await markAllFailed(ops);         // ops 1-50 already succeeded on server
  }
}

// Correct: track per-operation
Future<void> syncBatch(List<SyncOperation> ops) async {
  for (final op in ops) {
    try {
      await api.sendOperation(op);
      await _queue.markCompleted(op.id);
    } catch (e) {
      await _queue.markFailed(op.id, e.toString());
    }
  }
}

Offline-first is an architecture, not a feature. You cannot bolt it on later without significant refactoring. If your app will ever need to work offline, design for it from day one: local database as source of truth, write-through with sync queue, conflict resolution strategy chosen up front.

On this page