Offline-First Flutter: Drift & Isar Sync Patterns

Build production-grade offline-first Flutter apps with Drift and Isar. Learn sync strategies, conflict resolution, and data consistency for mobile SaaS.

0

Building mobile apps for emerging markets taught me something hard: connectivity is a feature, not a guarantee. Users expect your app to work whether they have signal or not. The moment you make that assumption, everything changes about how you architect your data layer.

Offline-first architecture flips the traditional client-server model. Instead of treating your app as a thin client that needs constant connectivity, you treat the device as the source of truth. The server becomes a sync target, not a gatekeeper. When you get this right, your app feels fast, responsive, and reliable. When you get it wrong, you end up with data conflicts, lost updates, and frustrated users.

This article walks through how to build this properly using Drift and Isar, two excellent local database options for Flutter. I’ll cover the architectural decisions, sync patterns, conflict resolution strategies, and the production details that separate a prototype from something you’d ship to millions of users.

Why Offline-First Matters for Mobile SaaS

Let’s start with why this matters. In markets like the UAE, India, Southeast Asia, and parts of Africa, users switch between WiFi and cellular constantly. They ride the metro. They go to areas with poor coverage. They travel. A typical user might lose connection 20 to 50 times a day depending on their commute and work patterns.

If your app requires constant connectivity, you’ve lost those users. More importantly, you’ve created a poor experience for everyone. Even in developed markets with solid infrastructure, network requests fail. Servers go down. Users close the app mid-sync. Your job is to make all of that invisible.

Offline-first architecture also makes your app faster. Users don’t wait for network round trips. They tap a button, see the result immediately, and the sync happens quietly in the background. That’s the UX users expect from native apps.

Choosing Between Drift and Isar

Both Drift and Isar are solid choices. They solve the same problem differently.

Drift is built on SQLite with code generation. If you’re comfortable with SQL and relational schemas, Drift feels natural. It gives you explicit control over your queries and relationships. The learning curve is gentle if you’ve worked with SQL before.

Isar is a NoSQL document database optimized for mobile. It’s faster for many patterns, especially when you’re working with deeply nested objects. It has built-in sync capabilities and is easier to work with if you prefer not to think about schema migrations.

For this article, I’ll focus on Drift because the relational model maps more clearly to typical SaaS data structures, and the explicit query patterns make sync logic easier to reason about. The principles apply to Isar, but the implementation details differ.

The Core Architecture: Three Layers

An offline-first app needs three distinct layers working together:

  • Local Layer: Your device database (Drift). This is always available and is your source of truth for the current state.
  • Sync Layer: The logic that detects changes, packages them, sends them to the server, and merges responses back into your local database.
  • Remote Layer: Your backend API. This accepts changes, validates them, applies business logic, detects conflicts, and sends back the canonical state.

The magic happens in the sync layer. It needs to be smart about what to send, when to send it, how to handle failures, and how to merge responses without corrupting data.

Setting Up Drift for Sync

Start with a schema that includes metadata for sync. You need to track what’s been synced and what hasn’t.

import 'package:drift/drift.dart';

class Tasks extends Table {
  IntColumn get id => integer().primaryKey();
  TextColumn get title => text();
  TextColumn get description => text();
  DateTimeColumn get createdAt => dateTime().clientDefault(() => DateTime.now());
  DateTimeColumn get updatedAt => dateTime().clientDefault(() => DateTime.now());
  
  // Sync metadata
  BoolColumn get isSynced => boolean().withDefault(const Constant(false));
  IntColumn get syncAttempts => integer().withDefault(const Constant(0));
  DateTimeColumn get lastSyncAttempt => dateTime().nullable();
  TextColumn get remoteId => text().nullable();
}

The key fields are isSynced, remoteId, and lastSyncAttempt. These let you track what needs to be sent to the server and handle retries intelligently.

When a user creates or modifies a task locally, you create or update the record with isSynced = false. When sync completes successfully, you set isSynced = true and store the remoteId. If sync encounters an issue, you increment syncAttempts and update lastSyncAttempt.

Building the Sync Engine

Your sync engine is the heart of the system. It runs periodically, whenever connectivity changes, or when the user manually refreshes. Here’s the basic flow:

class SyncEngine {
  final AppDatabase db;
  final ApiClient api;
  
  Future<void> sync() async {
    try {
      // Step 1: Push local changes to the server
      await _pushLocalChanges();
      
      // Step 2: Pull remote changes
      await _pullRemoteChanges();
      
      // Step 3: Detect and resolve conflicts
      await _resolveConflicts();
    } catch (e) {
      _handleSyncError(e);
    }
  }
  
  Future<void> _pushLocalChanges() async {
    final unsynced = await db.select(db.tasks)
        .where((t) => t.isSynced.equals(false))
        .get();
    
    for (final task in unsynced) {
      try {
        final response = await api.upsertTask(task.toJson());
        
        await db.update(db.tasks).replace(
          task.copyWith(
            remoteId: response['id'],
            isSynced: true,
            syncAttempts: 0,
          ),
        );
      } on ApiException catch (e) {
        // Handle specific error types: 409 = conflict, 4xx = validation, 5xx = retry
        await db.update(db.tasks).replace(
          task.copyWith(
            syncAttempts: task.syncAttempts + 1,
            lastSyncAttempt: DateTime.now(),
          ),
        );
      }
    }
  }
  
  Future<void> _pullRemoteChanges() async {
    final lastSync = await _getLastSyncTimestamp();
    final remoteChanges = await api.getChanges(since: lastSync);
    
    for (final change in remoteChanges) {
      final local = await db.select(db.tasks)
          .where((t) => t.remoteId.equals(change['id']))
          .getSingleOrNull();
      
      if (local == null) {
        // New remote record, insert it
        await db.into(db.tasks).insert(
          TasksCompanion(
            remoteId: Value(change['id']),
            title: Value(change['title']),
            description: Value(change['description']),
            updatedAt: Value(DateTime.parse(change['updatedAt'])),
            isSynced: const Value(true),
          ),
        );
      } else {
        // Existing record, check for conflicts
        if (local.updatedAt.isBefore(DateTime.parse(change['updatedAt']))) {
          // Remote is newer, take it
          await db.update(db.tasks).replace(
            local.copyWith(
              title: change['title'],
              description: change['description'],
              updatedAt: DateTime.parse(change['updatedAt']),
            ),
          );
        }
      }
    }
    
    await _updateLastSyncTimestamp();
  }
}

This is a simplified version showing the core pattern. You push changes, pull changes, and handle conflicts based on timestamps. In the next section, I’ll cover more sophisticated conflict resolution strategies.

Handling Conflicts: Last-Write-Wins vs. Smart Merge

The simplest conflict resolution strategy is last-write-wins (LWW). When the local and remote versions of a record differ, you take whichever has the newer timestamp. This works for many cases and is straightforward to implement.

However, consider a task with a title and a description. The user edits the title locally while offline. Meanwhile, another user edits the description on another device. Both changes are valid and represent independent updates to different fields. Using only LWW means one change takes precedence over the other, even though they don’t actually conflict.

A more sophisticated approach is field-level conflict resolution. You track which fields changed and merge them intelligently, preserving non-conflicting updates from both sides.

class ConflictResolver {
  Map<String, dynamic> resolveTaskConflict({
    required TaskRecord local,
    required Map<String, dynamic> remote,
    required Map<String, dynamic> remoteChanges,
  }) {
    // Start with remote as the base
    final resolved = Map<String, dynamic>.from(remote);
    
    // If the local change is newer and the remote didn't touch this field,
    // keep the local change
    if (local.title != remote['title'] && 
        !remoteChanges.containsKey('title')) {
      resolved['title'] = local.title;
    }
    
    if (local.description != remote['description'] && 
        !remoteChanges.containsKey('description')) {
      resolved['description'] = local.description;
    }
    
    // For fields that both sides changed, use last-write-wins
    if (local.title != remote['title'] && 
        remoteChanges.containsKey('title')) {
      if (local.updatedAt.isAfter(DateTime.parse(remote['updatedAt']))) {
        resolved['title'] = local.title;
      }
    }
    
    return resolved;
  }
}

This strategy requires your backend to communicate which fields changed on the remote side. It’s additional work, but it preserves data from both sides when changes don’t actually overlap.

Optimistic Updates: The UX Game Changer

Users shouldn’t wait for sync to complete before seeing their changes. The moment they tap save, update the local database and show the new state immediately. Sync happens in the background.

Future<void> updateTask(TaskRecord task) async {
  // Update local immediately
  final updated = task.copyWith(
    title: newTitle,
    updatedAt: DateTime.now(),
    isSynced: false,
  );
  
  await db.update(db.tasks).replace(updated);
  
  // Notify UI
  _taskStream.add(updated);
  
  // Sync in the background
  _syncEngine.sync().catchError((e) {
    // If sync encounters an issue, show a snackbar or retry banner
    _handleSyncFailure(e, task);
  });
}

The key is that the user sees the update immediately, even if the network is slow or offline. If sync encounters an issue, you show a retry button. If it succeeds, the sync layer updates the isSynced flag and remoteId silently.

Retry Logic and Exponential Backoff

Network issues are temporary. Your sync engine should retry intelligently, not immediately.

class RetryPolicy {
  static Duration getBackoffDuration(int attemptCount) {
    // Exponential backoff with jitter
    final baseDelay = Duration(seconds: 1 << attemptCount); // 1s, 2s, 4s, 8s...
    final jitter = Duration(milliseconds: Random().nextInt(1000));
    final maxDelay = Duration(minutes: 10);
    
    final total = baseDelay + jitter;
    return total > maxDelay ? maxDelay : total;
  }
  
  static bool shouldRetry(int attemptCount, Exception error) {
    // Validation errors (4xx) typically indicate data issues that won't resolve with retry
    if (error is ApiException && error.statusCode >= 400 && error.statusCode < 500) {
      return false;
    }
    
    // Network errors and server errors (5xx) are candidates for retry
    return attemptCount < 5;
  }
}

This approach prevents excessive requests to your backend when the network is unavailable. After 5 failed attempts, you stop automatic retries and show the user a persistent retry button.

Multi-Device Sync and Vector Clocks

When users have multiple devices, sync becomes more complex. A record might be created on Device A, modified on Device B while offline, and then synced from both devices to the server. How do you determine which change came last?

Timestamps alone can be unreliable because device clocks may be out of sync. Vector clocks offer a way to track causality across devices, helping you understand the order of events even when timestamps are uncertain.

class VectorClock {
  final Map<String, int> clock; // deviceId -> version
  
  VectorClock(this.clock);
  
  VectorClock increment(String deviceId) {
    final newClock = Map<String, int>.from(clock);
    newClock[deviceId] = (newClock[deviceId] ?? 0) + 1;
    return VectorClock(newClock);
  }
  
  bool happensBefore(VectorClock other) {
    bool anyLess = false;
    for (final device in clock.keys) {
      if ((clock[device] ?? 0) > (other.clock[device] ?? 0)) {
        return false; // This clock is not less than other
      }
      if ((clock[device] ?? 0) < (other.clock[device] ?? 0)) {
        anyLess = true;
      }
    }
    return anyLess;
  }
  
  bool concurrent(VectorClock other) {
    return !happensBefore(other) && !other.happensBefore(this);
  }
}

When two changes are concurrent (neither happened strictly before the other), you need additional logic to resolve them. This could be a merge algorithm, a last-write-wins tiebreaker based on device ID, or even showing the user both versions and asking which they prefer.

Handling Connectivity Changes

Your sync engine should respond to connectivity changes, not just run on a timer.

class ConnectivityManager {
  final Connectivity _connectivity = Connectivity();
  final SyncEngine _syncEngine;
  
  void startMonitoring() {
    _connectivity.onConnectivityChanged.listen((result) {
      if (result == ConnectivityResult.mobile || 
          result == ConnectivityResult.wifi) {
        // Connection restored, trigger sync immediately
        _syncEngine.sync();
      }
    });
  }
}

This ensures that the moment connectivity is restored, you attempt to sync. Users see their offline changes propagate within seconds.

Data Consistency: Transactions and Atomicity

When syncing, you often need to update multiple related records atomically. If one update encounters an issue, you don’t want partial updates.

Future<void> syncTaskWithSubtasks(TaskRecord task) async {
  await db.transaction(() async {
    // Update the task
    await db.update(db.tasks).replace(task.copyWith(isSynced: true));
    
    // Update all related subtasks
    final subtasks = await (db.select(db.subtasks)
        ..where((s) => s.taskId.equals(task.id)))
        .get();
    
    for (final subtask in subtasks) {
      await db.update(db.subtasks).replace(
        subtask.copyWith(isSynced: true),
      );
    }
  });
}

Drift’s transaction support ensures that either all updates succeed or none do. This prevents inconsistent states where some records are synced and others are not.

Monitoring Sync Health

In production, you need visibility into sync behavior. Track key metrics:

  • Sync duration: How long does a full sync take?
  • Failed syncs: How many records encountered issues during sync and why?
  • Conflict rate: How often do conflicts occur?
  • Data staleness: How long since the last successful sync?
class SyncMetrics {
  DateTime? lastSuccessfulSync;
  int failedRecords = 0;
  int conflictCount = 0;
  Duration lastSyncDuration = Duration.zero;
  
  bool isStale(Duration threshold) {
    if (lastSuccessfulSync == null) return true;
    return DateTime.now().difference(lastSuccessfulSync!) > threshold;
  }
  
  void logSyncAttempt(Duration duration, int failed, int conflicts) {
    lastSyncDuration = duration;
    failedRecords = failed;
    conflictCount = conflicts;
    if (failed == 0) {
      lastSuccessfulSync = DateTime.now();
    }
  }
}

Use this data to show users a sync status indicator and to alert yourself to systemic issues in your backend.

Testing Offline Scenarios

You can’t test offline-first properly without actually simulating offline conditions. Use mock APIs and deliberate failures.

class MockApiClient implements ApiClient {
  bool simulateOffline = false;
  bool simulateConflict = false;
  
  @override
  Future<Map<String, dynamic>> upsertTask(Map<String, dynamic> task) async {
    if (simulateOffline) {
      throw NetworkException('No internet');
    }
    
    if (simulateConflict) {
      throw ApiException(409, 'Conflict detected');
    }
    
    // Return mock response
    return {'id': 'remote-123', ...task};
  }
}

Write tests that toggle these flags and verify that your sync engine handles each scenario correctly.

Production Checklist

Before shipping an offline-first app, verify these points:

  • Sync retries with exponential backoff and a maximum attempt limit.
  • Unsynced changes persist across app restarts.
  • Conflicts are detected and resolved consistently.
  • Sync doesn’t block the UI thread.
  • Users can see which records are synced and which are pending.
  • Network issues show clear messaging, not silent behavior.
  • Sync metrics are logged for debugging.
  • The app works completely offline, not just degraded mode.

Conclusion

Offline-first architecture is complex, but it’s the right approach for mobile SaaS in markets where connectivity is unreliable. Drift and Isar give you the tools to build it, but the patterns matter more than the framework.

The key principles are straightforward: treat the device as the source of truth, sync changes asynchronously in the background, handle issues gracefully, and resolve conflicts intelligently. When you get these right, your app feels fast and reliable regardless of network conditions.

Start with a simple sync engine and last-write-wins conflict resolution. As your product matures and you understand your data patterns better, layer in more sophisticated strategies like field-level merging and vector clocks. Most apps don’t need the complexity until they do.

The engineers building consumer apps in emerging markets figured this out years ago. The rest of us are catching up.

What’s the difference between Drift and Isar for offline-first apps?

Drift is a relational database built on SQLite with explicit SQL queries and code generation. Isar is a NoSQL document database optimized for mobile with faster performance on many patterns. For SaaS with relational data, Drift’s explicit schema and queries make sync logic easier to reason about. Isar is faster for nested objects and has built-in sync features, but requires different mental models if you’re coming from SQL.

How do I prevent data loss when sync encounters issues?

Store changes locally first with an isSynced flag, then sync asynchronously. If sync encounters an issue, the local record remains unchanged and you retry later with exponential backoff. Only mark a record as synced after the server confirms it. Use database transactions for multi-record updates so either all succeed or none do. Show users which records are pending sync and provide a manual retry button.

What’s the best conflict resolution strategy?

Start with last-write-wins based on timestamps, it works for most cases. As your app grows, move to field-level resolution where you track which fields changed on each side and merge non-conflicting changes. For complex scenarios, use vector clocks to understand causality across devices. The strategy depends on your data model and business logic, not the database choice.

How often should I sync?

Sync immediately when connectivity is restored, when the user manually requests it, and on a periodic timer (every 5 to 15 minutes depending on your use case). Don’t block the UI during sync. Run it on a background isolate or use WorkManager for Android and BackgroundTasks for iOS to continue syncing even when the app is backgrounded.

How do I handle multi-device sync?

Assign each device a unique ID and include it in your sync protocol. Use timestamps or vector clocks to determine update order. For concurrent changes, implement a merge algorithm or use a last-write-wins tiebreaker with device ID as the secondary key. The server is the source of truth for resolving conflicts across devices, not the client.

Leave a Reply

Your email address will not be published. Required fields are marked *