Skip to content
FlutterLearn

Advanced · Lesson 7 · 22 min read

Offline-First Sync and Conflict Resolution

Design a resilient Flutter data flow that works offline, queues writes, retries safely and resolves edits made on multiple devices.

Updated September 24, 2026

What you will learn

  • Separate the local source of truth from the remote synchronization process
  • Represent pending, synced and failed changes explicitly
  • Make retries safe with idempotent mutation ids
  • Choose a conflict policy that matches the product
  • Test sync behaviour without depending on a live network

Offline-first does not mean adding a cache to an online app. It means the local database is the app's immediate source of truth and synchronization is a background process that may be delayed, repeated or rejected. The UI must remain useful when the network is slow, absent or temporarily wrong.

Start with two timelines

A local edit and a server acknowledgement happen at different times. Treating them as one event creates fragile screens: a spinner never ends offline, a failed request loses the user's work, or a retry creates a duplicate record.

  • The local timeline: apply the user's edit immediately and show it.
  • The sync timeline: upload the change, receive an acknowledgement or record a failure.
  • The conflict timeline: decide what to do if the server changed the same record first.

Represent sync state explicitly

lib/sync/sync_state.dart
enum SyncStatus {
  synced,
  pending,
  failed,
  conflict,
}

class LocalDocument {
  const LocalDocument({
    required this.id,
    required this.title,
    required this.revision,
    required this.updatedAt,
    this.syncStatus = SyncStatus.synced,
    this.lastError,
  });

  final String id;
  final String title;
  final int revision;
  final DateTime updatedAt;
  final SyncStatus syncStatus;
  final String? lastError;

  LocalDocument copyWith({
    String? title,
    int? revision,
    DateTime? updatedAt,
    SyncStatus? syncStatus,
    String? lastError,
  }) {
    return LocalDocument(
      id: id,
      title: title ?? this.title,
      revision: revision ?? this.revision,
      updatedAt: updatedAt ?? this.updatedAt,
      syncStatus: syncStatus ?? this.syncStatus,
      lastError: lastError,
    );
  }
}

A pending document is still real data. Do not hide it from the list because it has not reached the server yet. A small status icon or label can explain what is happening without blocking the user.

Queue mutations, not screens

The queue should contain intent that can be retried, not a reference to a widget or a captured closure. Give every mutation a stable client id. The server can treat a repeated client id as the same operation instead of creating a duplicate.

lib/sync/pending_mutation.dart
class PendingMutation {
  const PendingMutation({
    required this.clientMutationId,
    required this.documentId,
    required this.baseRevision,
    required this.title,
  });

  final String clientMutationId;
  final String documentId;
  final int baseRevision;
  final String title;

  Map<String, Object> toJson() => {
    'clientMutationId': clientMutationId,
    'documentId': documentId,
    'baseRevision': baseRevision,
    'title': title,
  };
}

Keep the repository boundary clean

lib/sync/sync_repository.dart
abstract interface class LocalStore {
  Future<List<LocalDocument>> loadDocuments();
  Future<void> saveDocument(LocalDocument document);
  Future<void> enqueue(PendingMutation mutation);
  Future<List<PendingMutation>> pendingMutations();
  Future<void> removeMutation(String clientMutationId);
}

abstract interface class RemoteApi {
  Future<PushResult> push(PendingMutation mutation);
  Future<List<LocalDocument>> pull({required int cursor});
}

sealed class PushResult {
  const PushResult();
}

class PushAccepted extends PushResult {
  const PushAccepted(this.document);
  final LocalDocument document;
}

class PushConflict extends PushResult {
  const PushConflict(this.serverDocument);
  final LocalDocument serverDocument;
}

The UI depends on a local store. The sync engine depends on both stores. The network client never reaches into widgets, and a test can replace both boundaries with fakes.

Apply locally, then synchronise

lib/sync/sync_engine.dart
class SyncEngine {
  SyncEngine(this.local, this.remote);

  final LocalStore local;
  final RemoteApi remote;

  Future<void> editTitle(LocalDocument current, String title) async {
    final mutation = PendingMutation(
      clientMutationId: _newMutationId(),
      documentId: current.id,
      baseRevision: current.revision,
      title: title,
    );

    final optimistic = current.copyWith(
      title: title,
      revision: current.revision + 1,
      updatedAt: DateTime.now(),
      syncStatus: SyncStatus.pending,
    );
    await local.saveDocument(optimistic);
    await local.enqueue(mutation);
  }

  Future<void> flush() async {
    for (final mutation in await local.pendingMutations()) {
      final result = await remote.push(mutation);
      switch (result) {
        case PushAccepted(:final document):
          await local.saveDocument(
            document.copyWith(syncStatus: SyncStatus.synced),
          );
          await local.removeMutation(mutation.clientMutationId);
        case PushConflict(:final serverDocument):
          await local.saveDocument(
            serverDocument.copyWith(syncStatus: SyncStatus.conflict),
          );
      }
    }
  }

  String _newMutationId() =>
      DateTime.now().microsecondsSinceEpoch.toString();
}

The important ordering is local save, queue, then network. If the process dies after the local save but before the queue write, the operation can be lost; use one database transaction for those two writes in a real implementation. If the network call fails, leave the mutation in the queue and mark the document as failed or pending.

Choose a conflict policy deliberately

PolicyGood fitCost
Last write winsPreferences or low-value text where simplicity mattersA valid edit can silently disappear
Server winsAuthoritative records and settings controlled by one systemThe user must re-enter local work
Client winsA single-user draft where the device is the main editorCan overwrite a newer edit from another device
Merge or manual reviewCollaborative documents and valuable user-authored contentMore metadata, UI and testing

Revision numbers make a basic conflict detectable: send the revision the user edited, and let the server reject the mutation if its current revision is newer. Do not call every timestamp comparison conflict resolution; clocks drift, and a server version is easier to reason about.

Retry with a bounded backoff

Dart
Duration retryDelay(int attempt) {
  final seconds = 1 << attempt.clamp(0, 5);
  return Duration(seconds: seconds);
}

Future<PushResult> pushWithRetry(
  RemoteApi remote,
  PendingMutation mutation,
) async {
  for (var attempt = 0; attempt < 6; attempt++) {
    try {
      return await remote.push(mutation);
    } on TransientNetworkError {
      if (attempt == 5) rethrow;
      await Future<void>.delayed(retryDelay(attempt));
    }
  }
  throw StateError('unreachable');
}

Test the unhappy paths

  • Edit while offline: the local document updates immediately and one mutation is queued.
  • The same flush runs twice: the server sees one client mutation id, not two records.
  • A request times out after the server accepts it: retrying is safe.
  • The server has a newer revision: the document becomes a visible conflict.
  • The app is killed between local save and sync: the queue resumes on the next launch.
test/sync_engine_test.dart
test('retrying a mutation does not duplicate it', () async {
  final remote = FakeRemoteApi();
  final engine = SyncEngine(FakeLocalStore(), remote);

  final mutation = PendingMutation(
    clientMutationId: 'm-1',
    documentId: 'doc-1',
    baseRevision: 3,
    title: 'Offline edit',
  );

  await remote.push(mutation);
  await remote.push(mutation);

  expect(remote.acceptedMutationIds, {'m-1'});
});

Fake the clock, local store and remote API in tests. A sync suite should be deterministic: you want to control the exact order of local writes, failures, retries and server responses rather than wait for a real network.

Official references

Continue with the primary Flutter and Dart documentation for this topic.

Key takeaways

  • Let the local database drive the UI; synchronization is a separate process.
  • Store pending intent durably, not closures or widget references.
  • Use server revisions and idempotency keys to make retries safe.
  • Choose a conflict policy based on the value of the data, not implementation convenience.
  • Test process death, timeouts, duplicate retries and conflicts as first-class flows.

Practice

Design a resilient sync flow

Add pull-based cursor sync to this engine, persist the queue in a transaction with the local document, and build a conflict screen that lets a user keep the local version, keep the server version or merge the text.

Show hints
  • Make pull application idempotent: replaying the same server page should produce the same local state.
  • Never delete a conflicting local version before the user chooses; keep enough metadata to show both sides.
  • Use a fake clock to test backoff without making the test suite wait in real time.

Lesson 6

Release Builds, Signing and CI/CD

Configure flavours and environments, sign Android and iOS builds properly, shrink your app, and automate the whole release in CI.