← 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 Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.

Cover image for Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.