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
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.
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
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
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
| Policy | Good fit | Cost |
|---|---|---|
| Last write wins | Preferences or low-value text where simplicity matters | A valid edit can silently disappear |
| Server wins | Authoritative records and settings controlled by one system | The user must re-enter local work |
| Client wins | A single-user draft where the device is the main editor | Can overwrite a newer edit from another device |
| Merge or manual review | Collaborative documents and valuable user-authored content | More 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
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('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 1
App Architecture That Survives Growth
Layer a Flutter codebase so features can be added by several people at once without the whole thing turning into spaghetti.
Lesson 2
Networking: REST APIs, JSON and Error Handling
Fetch data over HTTP, parse JSON into typed models, handle every failure mode, and show the result without jank.
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.