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
Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations
Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.
Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity
Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.
Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps
Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.