Offline-First Flutter Apps with Riverpod 2 & Drift: Async State Management and Sync Strategies

Build enterprise Flutter apps that work offline. Master Riverpod 2 async state, Drift SQLite caching, and delta sync strategies for unreliable connectivity.

0

Enterprise mobile apps live in the real world. Users lose signal in elevators, trains cut through tunnels, and coffee shop WiFi disappears mid-task. A production app needs to handle these moments gracefully. This is where offline-first architecture becomes essential.

Riverpod 2 and Drift give you the tools to build this properly. This article walks through concrete patterns for async state management, local caching, and syncing data back to your backend when connectivity returns.

Why Offline-First Matters

The traditional approach waits for a network request, shows a spinner, then updates the UI. When connectivity drops, the app feels unresponsive. Users see loading states, and their work sits in limbo.

Offline-first flips this: your app writes to local storage immediately, shows the user their changes right away, and syncs to the backend in the background. If the network is down, the app still works. When connectivity returns, sync happens automatically. The user never loses data.

This approach is especially critical for:

  • Task management and to-do apps where users expect changes to persist instantly
  • CRM and field service apps where workers operate in areas with poor coverage
  • Notification systems that must queue offline and replay on reconnect
  • Multi-user collaborative features where conflicts need intelligent resolution

The Stack: Riverpod 2, AsyncValue, and Drift

Riverpod 2 introduced AsyncNotifier and improved AsyncValue handling, making async state management cleaner and more predictable. Drift is a type-safe SQLite wrapper that generates boilerplate code for you. Together, they form a solid foundation.

Here’s the mental model:

  • Riverpod providers manage your app state and coordinate between local and remote data
  • AsyncValue represents the three states of async operations: loading, data, error
  • Drift handles local SQLite persistence with type safety
  • Delta sync tracks which records changed locally and pushes only those changes

Setting Up Drift for Local Caching

Start with a Drift database. Define your tables as Dart classes, and Drift generates the SQL and queries.

import 'package:drift/drift.dart';

part 'database.g.dart';

class Tasks extends Table {
  IntColumn get id => integer().primaryKey()();
  TextColumn get title => text()();
  TextColumn get description => text().nullable()();
  DateTimeColumn get dueDate => dateTime().nullable()();
  BoolColumn get completed => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get updatedAt => dateTime()();
  IntColumn get syncStatus => integer().withDefault(const Constant(0))();
}

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

  @override
  int get schemaVersion => 1;

  Future<Task> createTask(Task task) async {
    return into(tasks).insertReturning(task);
  }

  Future<void> updateTask(Task task) async {
    await update(tasks).replace(task);
  }

  Future<List<Task>> getLocalTasks() async {
    return select(tasks).get();
  }

  Future<List<Task>> getUnsyncedTasks() async {
    return (select(tasks)..where((t) => t.syncStatus.isNotEqualTo(1))).get();
  }
}

The syncStatus field is crucial: 0 means unsynced, 1 means synced. When the user creates or modifies a task offline, syncStatus stays 0. When sync completes, it flips to 1.

Initialize the database in your main app using Drift’s sqlite3 implementation:

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

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

final databaseProvider = Provider<AppDatabase>((ref) {
  return AppDatabase(_openConnection());
});

Building Async Providers with Riverpod 2

Riverpod 2’s AsyncNotifier makes handling async state straightforward. Create a provider that loads from local storage first, then fetches from the backend.

class TasksNotifier extends AsyncNotifier<List<Task>> {
  late final AppDatabase _db;
  late final TaskApiClient _api;

  @override
  Future<List<Task>> build() async {
    _db = ref.watch(databaseProvider);
    _api = ref.watch(taskApiClientProvider);

    final localTasks = await _db.getLocalTasks();
    return localTasks;
  }

  Future<void> createTask(String title, String? description) async {
    final task = Task(
      id: DateTime.now().millisecondsSinceEpoch,
      title: title,
      description: description,
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
      syncStatus: 0,
    );

    await _db.createTask(task);
    final current = state.asData?.value ?? [];
    state = AsyncValue.data([...current, task]);
  }

  Future<void> syncTasks() async {
    final unsynced = await _db.getUnsyncedTasks();
    if (unsynced.isEmpty) return;

    try {
      for (final task in unsynced) {
        await _api.upsertTask(task);
        final synced = task.copyWith(syncStatus: 1);
        await _db.updateTask(synced);
      }
      state = AsyncValue.data(await _db.getLocalTasks());
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

final tasksProvider = AsyncNotifierProvider<TasksNotifier, List<Task>>(
  () => TasksNotifier(),
);

This pattern is powerful: the provider starts with local data (instant UI response), and syncTasks() runs in the background when connectivity is available.

Detecting Connectivity and Triggering Sync

Use the connectivity_plus package to monitor network state and trigger sync automatically:

final connectivityProvider = StreamProvider<ConnectivityResult>((ref) {
  return Connectivity().onConnectivityChanged;
});

final autoSyncProvider = FutureProvider<void>((ref) async {
  final connectivity = ref.watch(connectivityProvider);
  final tasksNotifier = ref.read(tasksProvider.notifier);

  connectivity.whenData((result) {
    if (result != ConnectivityResult.none) {
      tasksNotifier.syncTasks();
    }
  });
});

In your main widget, watch autoSyncProvider to start the sync cycle:

class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(autoSyncProvider);

    return MaterialApp(
      home: TaskListScreen(),
    );
  }
}

Delta Sync: Only Push What Changed

Syncing every record every time is wasteful. Delta sync only sends records that changed since the last sync. Extend the database to track sync timestamps:

class Tasks extends Table {
  IntColumn get id => integer().primaryKey()();
  TextColumn get title => text()();
  DateTimeColumn get updatedAt => dateTime()();
  DateTimeColumn get syncedAt => dateTime().nullable()();
  IntColumn get syncStatus => integer().withDefault(const Constant(0))();
}

When syncing, only push tasks where updatedAt is after syncedAt:

Future<void> deltaSyncTasks() async {
  final lastSyncTime = await _db.getLastSyncTime() ?? DateTime(2000);

  final changedTasks = await (select(_db.tasks)
        ..where((t) => t.updatedAt.isAfter(lastSyncTime)))
      .get();

  if (changedTasks.isEmpty) return;

  try {
    final response = await _api.syncTasks(changedTasks);
    final now = DateTime.now();

    for (final taskId in response.syncedIds) {
      await _db.updateTaskSyncTime(taskId, now);
    }

    await _db.setLastSyncTime(now);
  } catch (e, st) {
    state = AsyncValue.error(e, st);
  }
}

Handling Conflicts and Merge Strategies

When a user edits a task offline and the same task is edited on another device, conflicts arise. The approach you choose depends on your data and use case.

Last-Write-Wins (LWW): The most recent change wins. Simple to implement and effective for single-user data. The most recent timestamp always takes precedence.

Future<void> resolveConflict(Task local, Task remote) async {
  final winner = local.updatedAt.isAfter(remote.updatedAt) ? local : remote;
  await _db.updateTask(winner.copyWith(syncStatus: 1));
}

Three-Way Merge: Compare local, remote, and the last known common state. Merge changes intelligently by checking which fields changed on each side. More complex but preserves data from both sources.

Future<void> threeWayMerge(
  Task local,
  Task remote,
  Task common,
) async {
  final merged = Task(
    id: local.id,
    title: local.title != common.title ? local.title : remote.title,
    description: local.description != common.description
        ? local.description
        : remote.description,
    completed: local.completed || remote.completed,
    updatedAt: DateTime.now(),
    syncStatus: 1,
  );
  await _db.updateTask(merged);
}

User Resolution: When conflicts occur, show a dialog and let the user choose. Best for critical data where data loss is unacceptable and user input is feasible.

Future<void> showConflictDialog(BuildContext context, Task local, Task remote) {
  return showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Conflict Detected'),
      content: Text('Task "${local.title}" was edited elsewhere. Keep your changes or use the remote version?'),
      actions: [
        TextButton(
          onPressed: () {
            threeWayMerge(local, remote, local);
            Navigator.pop(context);
          },
          child: const Text('Keep Mine'),
        ),
        TextButton(
          onPressed: () {
            _db.updateTask(remote.copyWith(syncStatus: 1));
            Navigator.pop(context);
          },
          child: const Text('Use Remote'),
        ),
      ],
    ),
  );
}

Building the UI with AsyncValue

Riverpod’s AsyncValue makes rendering different states simple:

class TaskListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tasksAsync = ref.watch(tasksProvider);
    final connectivity = ref.watch(connectivityProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Tasks'),
        actions: [
          connectivity.when(
            data: (result) => result == ConnectivityResult.none
                ? const Tooltip(
                    message: 'Offline',
                    child: Padding(
                      padding: EdgeInsets.all(16),
                      child: Icon(Icons.cloud_off),
                    ),
                  )
                : const SizedBox(),
            loading: () => const SizedBox(),
            error: (_, __) => const SizedBox(),
          ),
        ],
      ),
      body: tasksAsync.when(
        data: (tasks) => ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (context, index) {
            final task = tasks[index];
            return ListTile(
              title: Text(task.title),
              subtitle: Text(task.description ?? ''),
              trailing: task.syncStatus == 0
                  ? const Icon(Icons.cloud_upload_outlined)
                  : const Icon(Icons.cloud_done),
            );
          },
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(
          child: Text('Error: $error'),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTaskDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddTaskDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('New Task'),
        content: TextField(
          controller: controller,
          decoration: const InputDecoration(hintText: 'Task title'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          TextButton(
            onPressed: () {
              ref.read(tasksProvider.notifier).createTask(controller.text, null);
              Navigator.pop(context);
            },
            child: const Text('Add'),
          ),
        ],
      ),
    );
  }
}

Syncing User Profiles and Notifications

The same pattern scales to other data types. For user profiles, you might sync less frequently:

class UserNotifier extends AsyncNotifier<User?> {
  late final AppDatabase _db;
  late final UserApiClient _api;

  @override
  Future<User?> build() async {
    _db = ref.watch(databaseProvider);
    _api = ref.watch(userApiClientProvider);

    return _db.getLocalUser();
  }

  Future<void> updateProfile(String name, String email) async {
    final user = state.asData?.value;
    if (user == null) return;

    final updated = user.copyWith(
      name: name,
      email: email,
      syncStatus: 0,
      updatedAt: DateTime.now(),
    );

    await _db.updateUser(updated);
    state = AsyncValue.data(updated);
  }

  Future<void> syncProfile() async {
    final user = state.asData?.value;
    if (user == null || user.syncStatus == 1) return;

    try {
      await _api.updateUser(user);
      final synced = user.copyWith(syncStatus: 1);
      await _db.updateUser(synced);
      state = AsyncValue.data(synced);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

final userProvider = AsyncNotifierProvider<UserNotifier, User?>(
  () => UserNotifier(),
);

For notifications, queue them locally and replay when online:

class NotificationQueue {
  final AppDatabase _db;

  NotificationQueue(this._db);

  Future<void> queueNotification(AppNotification notification) async {
    await _db.createNotification(
      notification.copyWith(synced: false),
    );
  }

  Future<void> processQueue(NotificationApiClient api) async {
    final pending = await _db.getUnsentNotifications();
    for (final notif in pending) {
      try {
        await api.sendNotification(notif);
        await _db.markNotificationSynced(notif.id);
      } catch (e) {
        // Retry on next sync cycle
      }
    }
  }
}

Testing Offline Behavior

Test offline scenarios without actually disconnecting. Mock the connectivity provider:

void main() {
  testWidgets('Task persists offline', (WidgetTester tester) async {
    final container = ProviderContainer(
      overrides: [
        connectivityProvider.overrideWithValue(
          const AsyncValue.data(ConnectivityResult.none),
        ),
      ],
    );

    await tester.pumpWidget(
      UncontrolledProviderScope(
        container: container,
        child: const MyApp(),
      ),
    );

    expect(find.byType(TaskListScreen), findsOneWidget);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField), 'Buy milk');
    await tester.tap(find.byType(AlertDialog).last);
    await tester.pumpAndSettle();

    expect(find.text('Buy milk'), findsOneWidget);
    expect(find.byIcon(Icons.cloud_upload_outlined), findsWidgets);
  });
}

Monitoring Sync Performance

In production, track sync metrics to catch issues early:

class SyncMetrics {
  final int recordsAttempted;
  final int recordsSynced;
  final int conflictsResolved;
  final Duration duration;
  final DateTime timestamp;

  SyncMetrics({
    required this.recordsAttempted,
    required this.recordsSynced,
    required this.conflictsResolved,
    required this.duration,
    required this.timestamp,
  });
}

final syncMetricsProvider = StateNotifierProvider<SyncMetricsNotifier, List<SyncMetrics>>(
  (ref) => SyncMetricsNotifier(),
);

class SyncMetricsNotifier extends StateNotifier<List<SyncMetrics>> {
  SyncMetricsNotifier() : super([]);

  void recordSync(SyncMetrics metrics) {
    state = [...state, metrics];
    if (state.length > 100) {
      state = state.sublist(state.length - 100);
    }
  }
}

Send these metrics to your analytics service to monitor sync health and latency.

Conclusion

Building offline-first apps is essential for enterprise mobile development. Riverpod 2 gives you clean async state management, Drift provides type-safe local storage, and delta sync keeps your backend in sync without unnecessary traffic.

The key patterns are straightforward: load from local storage first, sync in the background, handle conflicts gracefully, and monitor sync health. Start with a single entity like tasks, get the pattern right, then scale to your entire data model.

Your users will notice the difference immediately. Changes appear instantly, the app works offline, and data stays consistent. That’s the experience enterprise apps should deliver.

What’s the difference between AsyncNotifier and FutureProvider?

FutureProvider is simpler and good for one-off async operations. AsyncNotifier gives you more control: you can manually update state, combine multiple async operations, and manage complex workflows like sync. Use AsyncNotifier when you need to update state from multiple sources or trigger actions like syncTasks().

How do I handle schema migrations in Drift?

Drift auto-generates migration code. When you change a table, increment schemaVersion and define a migration function in your database class. Drift will run the migration on first launch. For complex migrations, write raw SQL in the migration function.

Should I sync after every change or batch changes?

Batch changes. Syncing after every tap creates excessive network requests. Instead, sync when connectivity returns, periodically (every 30 seconds), or when the user explicitly requests it. Batch syncing is more efficient and reduces battery drain.

How do I handle large datasets offline?

Use pagination and lazy loading. Only cache the data the user needs. Implement a cache eviction policy to remove old records. Consider splitting data into smaller Drift tables by entity type, and only sync tables that changed.

Can I use Riverpod 2 with GetX or other state management libraries?

Yes, but mixing state managers adds complexity. Riverpod is powerful enough for most apps. If you need GetX for navigation, keep state management in Riverpod and use GetX only for routing.

Leave a Reply

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