Flutter Firebase Last-Write-Wins Sync: Preventing Cloud Edits from Clobbering Local
Building an offline-first task app like ROCIs Tasks means wrestling with data synchronization. You want users to feel productive regardless of connectivity, but that introduces a critical challenge: what happens when the same task is edited on two different devices, or offline, and then comes online? We hit a frustrating wall where our app's startup routine would consistently clobber newer edits made on other devices.
The Mystery: Local Edits Overwriting Cloud Changes
The symptom was subtle but infuriating: a task completed on a tablet would reappear as incomplete on a phone after a fresh app launch. Or, a task edited on the web would revert to an older state on Android. Digging into the logs, we found that TaskSyncManager.uploadLocalDataToCloud() was the culprit. On every launch or sign-in, this function would blindly set() every local task to Firebase. It didn't care if a task had been updated more recently in the cloud; if it was local, it was pushed, overwriting whatever was there.
Our TaskConflictResolver was trying to help, but it was guessing. It used completedAt ?? createdAt to determine recency. This was fundamentally flawed because a task's completion status isn't the only thing that changes, and createdAt is static. Furthermore, our live listener had a rule to "keep local completion," which meant even if another device un-completed a task, our local app would stubbornly re-complete it on the next sync event.
The Root Cause: Missing a Universal modifiedAt Timestamp and Blind Uploads
The core problem was a lack of a reliable, universally updated modifiedAt timestamp on our Task model. Without it, our sync logic had no definitive way to tell which version of a task was truly the latest. The completedAt ?? createdAt heuristic was insufficient. Coupled with this, the uploadLocalDataToCloud() function performed a brute-force upload, assuming local data was always authoritative, which is a dangerous assumption in a multi-device, offline-first environment.
The Solution & Code Architecture: Introducing modifiedAt and Smart Uploads
We implemented a robust Last-Write-Wins (LWW) synchronization strategy. Here's how:
-
New
Task.modifiedAtField: We added a nullablemodifiedAtfield (@HiveField(24)) to ourTaskmodel, reflecting a FirestoremodifiedAttimestamp. This field is nowtouch()ed on every local edit — provider mutations, Google Tasks back-sync, widget background completion, everything.
// task.dart @HiveField(24) DateTime? modifiedAt; // Call this on any local task mutation void touch() { modifiedAt = DateTime.now(); } // Used for conflict resolution DateTime get lastModified => modifiedAt ?? completedAt ?? createdAt; Cloud Edit Time Logic: When comparing against cloud versions, the cloud edit time is now
modifiedAt ?? updatedAt. Crucially, we prioritize the device clock formodifiedAtso that a device compares its own edits on a consistent timeline. Server time is only used as a fallback for legacy documents or older app versions that might not havemodifiedAtyet.Per-User Upload Watermark: To prevent blind uploads, we introduced a
tasks_upload_watermark_<uid>stored locally. This watermark tracks the last successful upload timestamp for a user. On startup,TaskSyncManagernow only considers tasks edited since this watermark for upload.
* It fetches the `modifiedAt` (or `updatedAt`) for these potentially conflicting cloud tasks in chunks (using Firestore's `whereIn` with 30-ID batches).
* It then *batch-uploads* only the tasks that are either missing in the cloud or are newer locally than their cloud counterparts.
* If the app is offline, no upload occurs, and the watermark is not advanced, ensuring consistency.
- Refined Conflict Resolution: Both the
TaskConflictResolverand the "keep local completion" listener rules now consistently comparelastModified(which prioritizes our newmodifiedAt). This means if a task is un-completed on another device, that change will now correctly propagate to the local device.
Key Lessons for Other Devs
- Timestamp Everything: For any data that can be edited across multiple clients, a reliable
modifiedAttimestamp is non-negotiable. It's the bedrock of robust conflict resolution. - Prefer Local Clock for Writer Comparisons: When comparing edits from the same writer (device), using the device's clock for
modifiedAtprovides consistency. Server time is best reserved as a fallback or for documenting when a record truly hit the backend, not for primary conflict resolution across clients. - Don't Blindly Upload: Never assume local data is always the source of truth. Implement smart, differential uploads based on timestamps and watermarks to avoid clobbering newer data from other sources.
This fix was a significant step towards a truly robust offline-first experience for ROCIs Tasks, ensuring data integrity and a much smoother user experience across devices.
This came out of building ROCIs Tasks, your offline-first task manager: https://tasks.rocisapps.com?utm_source=devto&utm_medium=article&utm_campaign=devlog_last_write_wins_task_sync_perf_2a1da73bc3&utm_content=article_outro

Top comments (0)