← Back to posts Cover image for Building Offline-First Flutter Apps: A Guide to Local Data Sync & Conflict Resolution

Building Offline-First Flutter Apps: A Guide to Local Data Sync & Conflict Resolution

· 4 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Building an app that works flawlessly in areas with slow or intermittent internet isn’t just a feature—it’s a necessity for user trust. The “offline-first” paradigm ensures your app is always functional, storing data locally first and synchronizing when possible. This guide walks through the core architecture, local data management, and the crucial sync logic you need to implement.

The Core Architecture: Local Source of Truth

The fundamental shift is making your local database the primary source of data for the UI. The remote server becomes a secondary, synchronization endpoint. A typical flow looks like this:

  1. User Action: Data is written immediately to the local SQLite database.
  2. UI Update: The app rebuilds using this local data, providing instant feedback.
  3. Background Sync: A sync engine periodically (or when connectivity is detected) pushes local changes and pulls remote updates.

Implementing the Local Layer with Drift

For the local database, Drift (formerly Moor) is an excellent choice. It provides a reactive, type-safe wrapper around SQLite. Let’s set up a basic Task model.

First, add drift and sqlite3_flutter_libs to your pubspec.yaml.

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:io';

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: track local modification & sync state
  DateTimeColumn get localUpdatedAt =>
      dateTime().withDefault(currentDateAndTime)();
  BoolColumn get isSynced => boolean().withDefault(const Constant(false))();
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  // Helper to get pending changes
  Future<List<Task>> getUnsyncedTasks() async {
    return (select(tasks)..where((t) => t.isSynced.equals(false))).get();
  }

  Future<void> markTaskAsSynced(int id) async {
    await (update(tasks)..where((t) => t.id.equals(id))).write(
      TasksCompanion(isSynced: const Value(true)),
    );
  }
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'app_db.sqlite'));
    return NativeDatabase(file);
  });
}

Building a Robust Sync Engine

The sync engine manages bidirectional data flow. A simple queue-based approach is effective. We’ll create a SyncService that processes local changes.

class SyncService {
  final AppDatabase _db;
  final RemoteApi _api; // Your own class for network calls

  Future<void> pushChanges() async {
    final unsyncedTasks = await _db.getUnsyncedTasks();
    for (final task in unsyncedTasks) {
      try {
        // Map your local task to a DTO for the API
        await _api.postTask(task.toRemoteModel());
        // On success, mark as synced
        await _db.markTaskAsSynced(task.id);
      } catch (e) {
        // Implement retry logic with exponential backoff
        print('Failed to sync task ${task.id}: $e');
        // The task remains isSynced = false for next attempt
      }
    }
  }

  Future<void> pullChanges() async {
    try {
      final List<RemoteTask> remoteTasks = await _api.fetchUpdatedTasks();
      for (final remoteTask in remoteTasks) {
        await _db.upsertTaskFromRemote(remoteTask);
      }
    } catch (e) {
      // Handle error, possibly storing a timestamp to fetch from later
    }
  }
}

Conflict Resolution: The Make-or-Break Detail

Conflicts occur when the same record is modified both locally and remotely between syncs. The most common strategy is “Last Write Wins” (LWW) using timestamps, but a more user-friendly approach is “Manual Merge Resolution.”

Here’s how to implement a simple LWW strategy using our localUpdatedAt and a serverUpdatedAt field (which you would add and populate from the remote).

Future<void> upsertTaskFromRemote(RemoteTask remoteTask) async {
  final localTask = await _db.getTask(remoteTask.id);
  if (localTask == null) {
    // New task from server
    await _db.into(_db.tasks).insert(TasksCompanion.insert(...));
  } else if (!localTask.isSynced) {
    // CONFLICT: We have a local unsynced change
    final localIsNewer = localTask.localUpdatedAt
        .isAfter(remoteTask.serverUpdatedAt);
    if (localIsNewer) {
      // Keep local version, but flag to push later
      // Optionally, notify user of conflict override
    } else {
      // Overwrite local with server version
      await (_db.update(_db.tasks)..where((t) => t.id.equals(remoteTask.id)))
          .write(TasksCompanion(
        title: Value(remoteTask.title),
        isCompleted: Value(remoteTask.isCompleted),
        isSynced: const Value(true), // It came from server
      ));
    }
  } else {
    // No local changes, just update with server data
    await (_db.update(_db.tasks)..where((t) => t.id.equals(remoteTask.id)))
        .write(TasksCompanion(
      title: Value(remoteTask.title),
      isCompleted: Value(remoteTask.isCompleted),
    ));
  }
}

Common Pitfalls to Avoid

  • Ignoring Sync State: Forgetting to track isSynced or modification timestamps will lead to data loops and lost updates.
  • Blocking the UI: Always perform sync operations in a background isolate or use a dedicated package like workmanager for periodic sync.
  • Assuming Connectivity: Use connectivity_plus to listen for network changes, but always attempt sync with a fallback retry mechanism, as connectivity detection can be unreliable.
  • Over-Syncing: Implement sensible throttling. Don’t sync on every keystroke; batch changes and sync at reasonable intervals or after specific user actions.

Moving Forward

This foundation gets you a reliable offline-first app. For production applications, consider leveraging specialized synchronization layers like PowerSync or Supabase’s real-time sync, which handle much of this complex logic, conflict resolution, and real-time updates for you. However, understanding the underlying patterns—local-first writes, background synchronization, and explicit conflict resolution—is key to building resilient apps for any network condition.

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.