DEV Community

Cover image for Flutter Firebase Last-Write-Wins Sync: Preventing Cloud Edits from Clobbering Local
ROCI
ROCI

Posted on

Flutter Firebase Last-Write-Wins Sync: Preventing Cloud Edits from Clobbering Local

Flutter Firebase Last-Write-Wins Sync: Preventing Cloud Edits from Clobbering Local

ROCIs Tasks calendar and board views

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:

  1. New Task.modifiedAt Field: We added a nullable modifiedAt field (@HiveField(24)) to our Task model, reflecting a Firestore modifiedAt timestamp. This field is now touch()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;
    
  2. Cloud Edit Time Logic: When comparing against cloud versions, the cloud edit time is now modifiedAt ?? updatedAt. Crucially, we prioritize the device clock for modifiedAt so 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 have modifiedAt yet.

  3. 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, TaskSyncManager now 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.
Enter fullscreen mode Exit fullscreen mode
  1. Refined Conflict Resolution: Both the TaskConflictResolver and the "keep local completion" listener rules now consistently compare lastModified (which prioritizes our new modifiedAt). 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 modifiedAt timestamp 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 modifiedAt provides 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)