← 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 Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.