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
Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease
Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.
Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows
Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.
Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows
Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.