Offline-first apps are powerful until two devices edit the same record, then sync, and you're left with conflicting changes. Handling sync conflicts isn't optional—it's a core part of building reliable offline systems. Today we'll explore practical strategies to detect, resolve, and communicate these conflicts to your users.
Last-Write-Wins for Simple Fields
The simplest conflict resolution strategy is last-write-wins (LWW). When two devices sync the same record, whichever change has the most recent timestamp wins. It's deterministic, requires minimal state, and works well for fields where the latest value is genuinely the correct one.
Imagine a task-management app. Device A sets a task's title to "Buy groceries" at 10:00 AM. Device B sets it to "Buy groceries and milk" at 10:05 AM. When they sync, 10:05 AM is newer, so "Buy groceries and milk" becomes the canonical version. Simple.
Here's how you'd track this in Dart:
class SyncRecord {
final String id;
final String title;
final DateTime lastModified;
final String deviceId; // Track which device made the change
SyncRecord({
required this.id,
required this.title,
required this.lastModified,
required this.deviceId,
});
SyncRecord applyRemoteChange(SyncRecord remote) {
// Last-write-wins: if remote is newer, use it
if (remote.lastModified.isAfter(lastModified)) {
return remote;
}
return this;
}
}The catch: LWW loses data. If Device B's change was deliberate and Device A's was a stale sync, you've silently discarded work. Use LWW only for fields where you're confident the latest timestamp reflects the user's intent—status flags, timestamps, simple text updates where overwrite is acceptable.
Version Numbers to Detect Conflicts
A version number (or revision number) on each record gives you visibility into whether a conflict actually exists. Instead of just comparing timestamps, you compare the version chain.
Imagine Device A reads version 3 of a customer record and edits the email. Device B reads version 3 and edits the phone. When they both try to sync, the server sees two edits based on the same parent version—that's a conflict. If Device A edits version 3 to version 4, and Device B edits version 3 independently, both incremented from the same ancestor.
Here's a practical implementation:
class VersionedRecord {
final String id;
final int version;
final Map data;
final DateTime lastModified;
final int baseVersion; // The version this device read
VersionedRecord({
required this.id,
required this.version,
required this.data,
required this.lastModified,
required this.baseVersion,
});
bool hasConflict(VersionedRecord remote) {
// Conflict if both edited the same base version
return baseVersion == remote.baseVersion && version != remote.version;
}
bool canMergeAutomatically(VersionedRecord remote) {
// Auto-merge if they edited different fields
final localKeys = data.keys.toSet();
final remoteKeys = remote.data.keys.toSet();
final intersection = localKeys.intersection(remoteKeys);
// No overlap means we can safely merge
return intersection.isEmpty;
}
}When you detect a conflict, you have context: both devices started from the same version and made independent changes. This lets you decide whether to merge field-by-field or ask the user.
Merge Field by Field Where Possible
Not every conflict requires user intervention. If Device A changed the email and Device B changed the phone number on the same contact record, merge both changes. They're independent.
I've seen this work well in production CRM systems. A user's address updated on one phone, their job title on another. When they sync, both updates land safely because they touched different fields.
Map mergeRecords(
Map local,
Map remote,
Map base,
) {
final merged = Map.from(base);
// Process each field independently
for (final key in {...local.keys, ...remote.keys}) {
final localValue = local[key];
final remoteValue = remote[key];
final baseValue = base[key];
// If only one side changed, use that change
if (localValue == baseValue && remoteValue != baseValue) {
merged[key] = remoteValue;
} else if (remoteValue == baseValue && localValue != baseValue) {
merged[key] = localValue;
} else if (localValue == remoteValue) {
// Both changed to the same value, no conflict
merged[key] = localValue;
} else if (localValue != baseValue && remoteValue != baseValue) {
// Both changed differently: true conflict on this field
merged[key] = {
'conflict': true,
'local': localValue,
'remote': remoteValue,
'base': baseValue,
'field': key,
};
}
}
return merged;
}This approach is powerful because it handles most real-world edits without friction. Three-way merge (comparing local, remote, and base versions) lets you distinguish between "I didn't touch this field" and "I changed this to the same value the server has."
Ask the User for Important Conflicts
When a field has genuine conflicts—both devices changed it to different values—show the user and let them decide. Don't hide this or make a guess that might discard important data.
In e-commerce systems I've built, if two devices set different shipping addresses or different payment methods on the same order, we surface that conflict. The user knows their intent; we just present the options clearly.
Here's how you'd structure a conflict resolution UI in Flutter:
class ConflictResolutionScreen extends StatefulWidget {
final List conflicts;
final Function(Map) onResolve;
const ConflictResolutionScreen({
required this.conflicts,
required this.onResolve,
});
@override
State createState() =>
_ConflictResolutionScreenState();
}
class _ConflictResolutionScreenState extends State {
late Map resolutions;
@override
void initState() {
super.initState();
resolutions = {};
}
void resolveConflict(String fieldName, dynamic chosenValue) {
setState(() {
resolutions[fieldName] = chosenValue;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Resolve conflicts')),
body: ListView.builder(
itemCount: widget.conflicts.length,
itemBuilder: (context, index) {
final conflict = widget.conflicts[index];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Conflicting field: ${conflict.fieldName}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'This device: ${conflict.localValue}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
'Other device: ${conflict.remoteValue}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => resolveConflict(
conflict.fieldName,
conflict.localValue,
),
child: const Text('Keep this device'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () => resolveConflict(
conflict.fieldName,
conflict.remoteValue,
),
child: const Text('Use other device'),
),
),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: widget.conflicts.length == resolutions.length
? () => widget.onResolve(resolutions)
: null,
child: const Icon(Icons.check),
),
);
}
}
class FieldConflict {
final String fieldName;
final dynamic localValue;
final dynamic remoteValue;
FieldConflict({
required this.fieldName,
required this.localValue,
required this.remoteValue,
});
}Present the conflict clearly: what changed, what each device has, and let the user pick. Don't force them into a modal if they're working through multiple conflicts—batch the UI so they can review everything at once.
Common Mistakes
- Ignoring timestamp precision: If both devices record a change at the same second, LWW becomes arbitrary. Use microsecond or millisecond precision and include a tiebreaker (like device ID) to ensure deterministic outcomes.
- Assuming merged fields never conflict: Field-level merge works until you have a multi-field constraint. For example, if email and email_verified must stay in sync, merging them independently breaks that guarantee. Version entire aggregates, not just fields.
- Showing conflicts for every edit: If you surface conflicts for every change, users get fatigue and start picking randomly. Merge automatically where you're confident, and only ask for genuine conflicts on critical fields.
- Losing the conflict history: Log which resolution the user picked. If something goes wrong later, you need to understand what happened. Store both the conflict and the chosen resolution.
- Syncing only when connected: Detect conflicts as soon as you try to push changes, not when the sync finishes. Let the user resolve while they're still in context.
Key Takeaways
- Last-write-wins is simple but lossy; use it only for fields where the latest value is always correct.
- Version numbers let you detect conflicts and understand what actually changed on each device.
- Merge records field by field using three-way merge logic (comparing local, remote, and base versions).
- Surface genuine conflicts to the user with a clear UI showing what each device has; let them choose.
- Log resolutions so you can audit and debug sync behavior later.
- Test with simulated offline scenarios where two devices edit the same record; production will find gaps in your strategy.






Comments
Loading comments…