Building Offline-First Flutter Apps: A Guide to Local Data Sync & Conflict Resolution
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
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:
- User Action: Data is written immediately to the local SQLite database.
- UI Update: The app rebuilds using this local data, providing instant feedback.
- 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
isSyncedor 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
workmanagerfor periodic sync. - Assuming Connectivity: Use
connectivity_plusto 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
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.
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.
Solving Flutter Web Memory Leaks: A Practical Guide to Identifying and Fixing Performance Issues
Flutter Web applications can suffer from increasing memory usage over time, leading to performance degradation. This post will delve into common causes of memory leaks in Flutter Web, provide practical debugging techniques using browser developer tools and Dart DevTools, and offer actionable strategies to identify and fix these issues for a smoother user experience.