← Back to posts Cover image for Mastering Offline-First Data Sync in Flutter: Strategies for Robust Mobile Apps

Mastering Offline-First Data Sync in Flutter: Strategies for Robust Mobile Apps

· 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 flawlessly without an internet connection is no longer a luxury—it’s a user expectation. Whether your users are on a subway, in a remote area, or just dealing with spotty Wi-Fi, an offline-first architecture ensures your app remains functional and responsive. The real magic, however, happens when connectivity is restored, and all those local changes sync seamlessly to your backend without data loss or conflicts. Let’s dive into how to master this.

The Core Challenge: More Than Just Caching

The biggest mistake is thinking of offline-first as simple caching. Caching is passive; offline-first is active. You must design your app’s data layer with the assumption that the network is a temporary, optional enhancement. The primary source of truth becomes the user’s device. This mindset shift leads to a robust architecture built on three pillars:

  1. Local Data Persistence: A reliable database on the device.
  2. Change Tracking: Knowing exactly what data was created, updated, or deleted while offline.
  3. Synchronization & Conflict Resolution: A resilient process to merge local and remote states.

1. Choosing Your Local Database

For structured data, you need a proper SQL database. sqflite is popular, but I recommend using an abstraction layer like drift (formerly moor) or floor. They provide type safety, compile-time queries, and better reactive streams.

Here’s a simple drift table with a crucial field for sync:

// data/local/app_database.dart
import 'package:drift/drift.dart';

part 'app_database.g.dart';

@DataClassName('TaskItem')
class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  
  // Sync metadata fields
  DateTimeColumn get localUpdatedAt => dateTime().nullable()();
  BoolColumn get isPendingSync => boolean().withDefault(const Constant(false))();
  TextColumn get syncOperation => text().withDefault(const Constant(''))(); // 'create', 'update', 'delete'
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());
  @override
  int get schemaVersion => 1;
}

The isPendingSync and syncOperation fields are your change trackers. Whenever a user modifies data, mark it for sync.

2. The Repository Pattern: Your Orchestration Layer

Don’t let your UI logic directly manage sync. Use a repository that sits between your widgets and your data sources (local DB and remote API).

// data/repository/task_repository.dart
class TaskRepository {
  final AppDatabase _localDb;
  final RemoteApiService _remoteApi;

  TaskRepository(this._localDb, this._remoteApi);

  // UI calls this. It only touches the local DB.
  Future<void> addTask(String title) async {
    await _localDb.into(_localDb.tasks).insert(
          TasksCompanion.insert(
            title: Value(title),
            isPendingSync: Value(true),
            syncOperation: Value('create'),
            localUpdatedAt: Value(DateTime.now()),
          ),
        );
  }

  // The crucial sync method
  Future<void> syncPendingTasks() async {
    // 1. Fetch pending changes from local DB
    final pendingTasks = await (_localDb.select(_localDb.tasks)
          ..where((t) => t.isPendingSync.equals(true)))
        .get();

    for (final task in pendingTasks) {
      try {
        // 2. Attempt to push to remote
        switch (task.syncOperation) {
          case 'create':
            final remoteId = await _remoteApi.createTask(task);
            await _localDb.update(_localDb.tasks).replace(
                  task.copyWith(
                    id: remoteId, // Update local ID with remote ID if needed
                    isPendingSync: false,
                    syncOperation: '',
                  ),
                );
            break;
          case 'update':
            await _remoteApi.updateTask(task);
            await _localDb.update(_localDb.tasks).replace(
                  task.copyWith(isPendingSync: false, syncOperation: ''));
            break;
          case 'delete':
            await _remoteApi.deleteTask(task.id);
            await (_localDb.delete(_localDb.tasks)..where((t) => t.id.equals(task.id))).go();
            break;
        }
      } catch (e) {
        // 3. Handle failure (e.g., keep pending flag, log error, retry later)
        print('Sync failed for task ${task.id}: $e');
        // Implement retry logic with exponential backoff
      }
    }
  }

  // Periodically pull remote changes
  Future<void> fetchLatestFromRemote() async {
    final serverTasks = await _remoteApi.fetchTasks();
    // Complex merge logic goes here. Simple example: overwrite local if server version is newer.
    for (final serverTask in serverTasks) {
      await _localDb.into(_localDb.tasks).insertOnConflictUpdate(serverTask);
    }
  }
}

3. Triggering Sync Reliably

Don’t rely on the user to pull-to-refresh. Use a combination of triggers:

  • App Resume: Sync when the app comes to the foreground.
  • Connectivity Change: Use the connectivity_plus package to listen for network restoration.
  • Periodic Sync: Use a timer or background tasks (with workmanager) for periodic attempts.
// logic/sync_service.dart
import 'package:connectivity_plus/connectivity_plus.dart';

class SyncService {
  final TaskRepository _repository;
  final Connectivity _connectivity = Connectivity();

  SyncService(this._repository) {
    _setupListeners();
  }

  void _setupListeners() {
    // Sync on connectivity change
    _connectivity.onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        _performSync();
      }
    });
  }

  Future<void> _performSync() async {
    await _repository.fetchLatestFromRemote();
    await _repository.syncPendingTasks();
  }
}

4. Conflict Resolution: The Hard Part

What happens if a task is edited on two different devices? You need a strategy. Common approaches include:

  • Last Write Wins (LWW): Use a timestamp (serverUpdatedAt) from your backend. The latest timestamp wins.
  • Manual Merge: For complex data, you might need to present conflicts to the user for resolution.
  • Operational Transformation (OT): For collaborative apps (like docs), this tracks operations rather than state.

Implement this in your fetchLatestFromRemote() merge logic by comparing timestamps or version numbers.

Common Pitfalls to Avoid

  • Ignoring Deletions: Your sync operation must handle delete. Use a “soft delete” (a isDeleted flag) if you need to sync deletions across devices.
  • Blocking the UI: Always perform sync operations in the background using async/await without await-ing on the UI thread if it’s not critical.
  • Forgetting Error Handling: Network requests will fail. Your sync logic must be idempotent (safe to retry) and handle partial failures gracefully.
  • Not Testing Offline: Rigorously test your app’s full workflow with airplane mode on. Simulate sync conflicts by modifying the same data on different clients.

Putting It All Together

Start with a solid local database (drift/isar). Wrap all data access in a repository that tracks pending changes. Build a dedicated sync service triggered by connectivity and lifecycle events. Finally, choose a clear conflict resolution strategy and stick to it.

By adopting this layered, offline-first approach, you build resilience into the core of your app. Your users get a fast, reliable experience regardless of their connection, and you gain a maintainable architecture that cleanly separates concerns. Happy building

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.