← Back to posts Cover image for Mastering Local-First Data Sync in Flutter: Strategies for Cross-Platform Consistency

Mastering Local-First Data Sync in Flutter: Strategies for Cross-Platform Consistency

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Building a Flutter app that works seamlessly across Android, iOS, Linux, and desktop is a superpower. But when your app needs to manage user data that syncs across these devices, you enter the tricky realm of local-first architecture. The promise is alluring: fast, always-available data, with sync as a background enhancement. The reality? Without a solid strategy, you’ll quickly face data conflicts, mysterious overwrites, and user frustration.

The core challenge is simple to state but hard to solve: How do you allow data to be modified independently on multiple devices, often while offline, and later merge those changes into a single, consistent truth without losing anyone’s work?

The Foundation: A Robust Local Data Store

Before you think about sync, you need a reliable single source of truth on each device. For this, sqflite (or sqlite3 for desktop) paired with an ORM like drift (formerly moor) is a battle-tested choice. It gives you the ACID guarantees you need.

// Example using drift for a simple "Task" model
import 'package:drift/drift.dart';
import 'package:drift/native.dart';

part 'database.g.dart';

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  // Crucial for sync
  DateTimeColumn get lastModified => dateTime().withDefault(currentDateAndTime)();
  TextColumn get deviceId => text()(); // Identifies origin device
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(NativeDatabase.memory()); // Use a file in production
  @override
  int get schemaVersion => 1;
}

The Heartbeat: Tracking Changes for Sync

A naive sync that just sends the entire database is inefficient and conflict-prone. The key is to track what has changed since the last successful sync. You can add a isSynced boolean flag or, more robustly, maintain a separate “changes” ledger.

// Adding a sync-focused change log table
class SyncLog extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get tableName => text()();
  IntColumn get recordId => integer()();
  TextColumn get operation => text()(); // 'INSERT', 'UPDATE', 'DELETE'
  DateTimeColumn get changedAt => dateTime().withDefault(currentDateAndTime)();
}

// When you modify a Task, you also log it.
Future<void> updateTask(Task task, String deviceId) async {
  await (update(tasks)..where((t) => t.id.equals(task.id))).write(TasksCompanion(
    title: Value(task.title),
    isCompleted: Value(task.isCompleted),
    lastModified: Value(DateTime.now().toUtc()),
    deviceId: Value(deviceId),
  ));

  // Log this change for the sync engine
  await into(syncLog).insert(SyncLogCompanion.insert(
    tableName: 'tasks',
    recordId: task.id,
    operation: 'UPDATE',
  ));
}

The Strategy: Conflict Resolution Logic

This is where most local-first sync quietly falls apart. Two devices modify the same task while offline. Which change wins?

Common Mistake: Using only lastModified. If device clocks are skewed, you will silently lose data.

Practical Solution: Implement deterministic conflict resolution. A simple but effective pattern is “Last Write Wins” with a tiebreaker.

  1. Compare lastModified timestamps (use UTC!).
  2. If they are exactly equal (unlikely but possible), use a deterministic tiebreaker like comparing the deviceId lexicographically. This ensures all devices will resolve the conflict identically.
Future<Task> resolveConflict(Task local, Task remote) {
  // Compare timestamps
  if (local.lastModified.isAfter(remote.lastModified)) {
    return local;
  } else if (remote.lastModified.isAfter(local.lastModified)) {
    return remote;
  } else {
    // Timestamps are equal, use deviceId as tiebreaker
    return (local.deviceId.compareTo(remote.deviceId) > 0) ? local : remote;
  }
  // More advanced: Merge fields if possible (e.g., combine titles? Usually not).
}

The Sync Flow: Putting It All Together

Your sync process, which can run periodically or when connectivity is detected, should follow a clear sequence:

  1. Fetch Changes from Server: Get all remote changes since your last known sync marker (lastSyncAt).
  2. Apply Remote Changes Locally: For each remote change, check if a conflict exists (does the local record have a newer or same-timestamp-but-different lastModified?). Use your resolveConflict logic. Apply the winning version to your local DB and log it as a local change if modified.
  3. Push Local Changes: Send all entries from your local SyncLog since lastSyncAt to the server.
  4. Acknowledge Sync: Clear the sent entries from your SyncLog and update your lastSyncAt timestamp.

This is a client-led sync. For a more robust system, consider using a CRDT (Conflict-Free Replicated Data Type) library for complex data structures, but for many apps, the timestamp+tiebreaker method is perfectly sufficient.

Key Takeaways for Consistency

  • Use a UUID for deviceId: Generate and store this once per app installation.
  • Always use UTC: DateTime.now().toUtc() is your friend. Never trust local device time for ordering.
  • Sync metadata is part of your data model: lastModified and deviceId are not optional fields.
  • Idempotency is crucial: Your sync protocol should be safe to run multiple times. Re-applying the same remote change should not create duplicates or errors.
  • Test offline scenarios aggressively: Simulate airplane mode, make changes on two emulators, and see what happens when they reconnect.

Local-first sync in Flutter is a marathon, not a sprint. By starting with a strong local database, explicitly tracking changes, and implementing a clear, deterministic conflict strategy, you build a foundation that keeps your users’ data consistent and trustworthy, no matter which device they pick up.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.