Most mobile apps today are built with an implicit assumption that feels invisible until it breaks, the user has a reliable internet connection. Remove that assumption, put the user on a subway, in a hospital with WiFi restrictions, in a rural area, or simply in a building with poor signal, and the app stops working. Forms can't be submitted. Data can't be loaded. Features are grayed out with a spinner that never resolves.
This is not a technical limitation. It is an architectural choice, and it is the wrong one for most mobile applications.
Local-first software is a design philosophy that inverts this assumption. Instead of treating the server as the source of truth and the device as a thin client, local-first apps treat the device as the primary computing environment. Data lives on the device first. The user can read, write, and interact with full functionality regardless of network state. When connectivity is available, the app synchronizes with the server, not because the app needs the server to function, but because the server enables collaboration and backup.
Flutter and SQLite are a natural fit for local-first architecture. Flutter's cross-platform capabilities mean the same app logic runs on iOS, Android, and desktop. SQLite is the world's most deployed database, embedded, reliable, and fast. Together, they give you the tools to build apps that work everywhere, always.
This guide covers the architecture, data layer, sync strategy, and conflict resolution patterns that make local-first Flutter apps production-ready.
Local-First Principles
The local-first philosophy, articulated by the Ink & Switch research lab in their influential 2019 essay, centers on seven ideals:
Fast - no waiting for network round-trips. Every read and write operates on local data.
Multi-device - data synchronizes across the user's devices without manual export/import.
Offline - full functionality without a network connection.
Collaboration - multiple users can work on the same data, with changes merging intelligently.
Longevity - the data is yours, accessible even if the service shuts down.
Privacy - sensitive data lives on the device, not in a cloud database by default.
User control - the user, not the server, is in control of their data.
Not every application needs all seven. A note-taking app might prioritize offline access and longevity. A collaborative document editor might prioritize real-time collaboration. The principles guide trade-offs rather than mandate a single implementation.
For this guide, we focus on the first four, fast local reads/writes, multi-device sync, offline capability, and basic conflict resolution, the foundation most local-first apps need.
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ Flutter App │
│ │
│ ┌──────────────┐ ┌─────────────────────────────┐ │
│ │ UI Layer │ │ Business Logic │ │
│ │ (Widgets) │◄───│ (Riverpod / BLoC / Cubit) │ │
│ └──────────────┘ └──────────┬──────────────────┘ │
│ │ │
│ ┌───────────▼──────────────────┐ │
│ │ Repository Layer │ │
│ │ (local-first data access) │ │
│ └──────────┬───────────────────┘ │
│ │ │
│ ┌───────────────────▼──────────────────┐ │
│ │ Local SQLite DB │ │
│ │ (drift / sqflite) │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────┘
│ Sync (when online)
│
┌──────────▼──────────┐
│ Remote Backend │
│ (NestJS / Supabase)│
└─────────────────────┘
The key architectural commitment, the UI always reads from and writes to the local SQLite database. The sync layer operates independently, it pushes local changes to the server and pulls remote changes to the local database, but the UI never waits for it.
Step 1 - Setting Up Drift (Type-Safe SQLite for Flutter)
Drift (formerly moor) is the recommended SQLite library for Flutter it provides type-safe queries, schema migrations, reactive streams, and a code generation model that makes SQLite feel like a first-class Dart API.
# pubspec.yaml
dependencies:
drift: ^2.14.0
sqlite3_flutter_libs: ^0.5.0
path_provider: ^2.1.0
path: ^1.8.0
connectivity_plus: ^5.0.0
http: ^1.1.0
dev_dependencies:
drift_dev: ^2.14.0
build_runner: ^2.4.0
Define your database schema with typed table definitions:
// lib/database/tables.dart
import 'package:drift/drift.dart';
// Notes table
class Notes extends Table {
TextColumn get id => text()();
TextColumn get title => text()();
TextColumn get content => text()();
TextColumn get userId => text()();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get syncedAt => dateTime().nullable()(); // null = not yet synced
IntColumn get localVersion => integer().withDefault(const Constant(1))();
@override
Set<Column> get primaryKey => {id};
}
// Sync log — tracks pending operations
class SyncOperations extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get entityType => text()(); // 'note', 'task', etc.
TextColumn get entityId => text()();
TextColumn get operation => text()(); // 'create', 'update', 'delete'
TextColumn get payload => text()(); // JSON-encoded entity state
DateTimeColumn get createdAt => dateTime()();
BoolColumn get isPending => boolean().withDefault(const Constant(true))();
}
Generate the database class:
// lib/database/app_database.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
part 'app_database.g.dart'; // generated by build_runner
@DriftDatabase(tables: [Notes, SyncOperations])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
},
onUpgrade: (m, from, to) async {
// Handle schema migrations here
},
);
// Generate the database file path
static LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'local_first.db'));
return NativeDatabase.createInBackground(file);
});
}
}
# Generate Drift code
dart run build_runner build --delete-conflicting-outputs
Step 2 - The Repository Pattern for Local-First Data Access
The repository layer abstracts the local-first behavior from the rest of the app, all writes go to SQLite immediately and queue a sync operation:
// lib/repositories/notes_repository.dart
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
class NotesRepository {
final AppDatabase _db;
final _uuid = const Uuid();
NotesRepository(this._db);
// Reactive stream — UI updates automatically when local data changes
Stream<List<Note>> watchAllNotes(String userId) {
return (_db.select(_db.notes)
..where((n) => n.userId.equals(userId))
..where((n) => n.isDeleted.equals(false))
..orderBy([(n) => OrderingTerm.desc(n.updatedAt)]))
.watch();
}
Future<Note?> getNoteById(String id) {
return (_db.select(_db.notes)..where((n) => n.id.equals(id)))
.getSingleOrNull();
}
// Create — writes to SQLite immediately, queues sync
Future<Note> createNote({
required String userId,
required String title,
required String content,
}) async {
final now = DateTime.now().toUtc();
final note = NotesCompanion.insert(
id: _uuid.v4(),
title: title,
content: content,
userId: userId,
createdAt: now,
updatedAt: now,
localVersion: const Value(1),
);
await _db.into(_db.notes).insert(note);
final created = await getNoteById(note.id.value);
// Queue sync operation
await _queueSyncOperation(
entityType: 'note',
entityId: note.id.value,
operation: 'create',
payload: _noteToJson(created!),
);
return created!;
}
// Update — local write first, then queue sync
Future<void> updateNote({
required String id,
String? title,
String? content,
}) async {
final now = DateTime.now().toUtc();
await (_db.update(_db.notes)..where((n) => n.id.equals(id)))
.write(NotesCompanion(
title: title != null ? Value(title) : const Value.absent(),
content: content != null ? Value(content) : const Value.absent(),
updatedAt: Value(now),
syncedAt: const Value(null), // mark as unsynced
));
final updated = await getNoteById(id);
if (updated != null) {
await _queueSyncOperation(
entityType: 'note',
entityId: id,
operation: 'update',
payload: _noteToJson(updated),
);
}
}
// Soft delete — never hard-delete locally, let sync handle it
Future<void> deleteNote(String id) async {
await (_db.update(_db.notes)..where((n) => n.id.equals(id)))
.write(NotesCompanion(
isDeleted: const Value(true),
updatedAt: Value(DateTime.now().toUtc()),
syncedAt: const Value(null),
));
await _queueSyncOperation(
entityType: 'note',
entityId: id,
operation: 'delete',
payload: json.encode({'id': id}),
);
}
Future<void> _queueSyncOperation({
required String entityType,
required String entityId,
required String operation,
required String payload,
}) async {
await _db.into(_db.syncOperations).insert(
SyncOperationsCompanion.insert(
entityType: entityType,
entityId: entityId,
operation: operation,
payload: payload,
createdAt: DateTime.now().toUtc(),
),
);
}
String _noteToJson(Note note) => json.encode({
'id': note.id,
'title': note.title,
'content': note.content,
'userId': note.userId,
'isDeleted': note.isDeleted,
'createdAt': note.createdAt.toIso8601String(),
'updatedAt': note.updatedAt.toIso8601String(),
'localVersion': note.localVersion,
});
}
The watch() method returns a reactive Stream, the UI subscribes to this stream and automatically rebuilds whenever the local database changes. No polling, no manual refresh, no setState after network calls.
Step 3 - Sync Engine
The sync engine runs in the background, pushing queued operations to the server and pulling remote changes:
// lib/sync/sync_engine.dart
import 'dart:async';
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;
import '../database/app_database.dart';
class SyncEngine {
final AppDatabase _db;
final String _apiBaseUrl;
final String _authToken;
StreamSubscription? _connectivitySubscription;
Timer? _periodicSyncTimer;
bool _isSyncing = false;
SyncEngine({
required AppDatabase db,
required String apiBaseUrl,
required String authToken,
}) : _db = db,
_apiBaseUrl = apiBaseUrl,
_authToken = authToken;
void start() {
// Sync immediately on startup
sync();
// Sync when connectivity is restored
_connectivitySubscription = Connectivity()
.onConnectivityChanged
.listen((result) {
if (result != ConnectivityResult.none) {
sync();
}
});
// Periodic sync every 5 minutes
_periodicSyncTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => sync(),
);
}
void stop() {
_connectivitySubscription?.cancel();
_periodicSyncTimer?.cancel();
}
Future<void> sync() async {
if (_isSyncing) return;
final connectivity = await Connectivity().checkConnectivity();
if (connectivity == ConnectivityResult.none) return;
_isSyncing = true;
try {
await _pushPendingOperations();
await _pullRemoteChanges();
} catch (e) {
// Sync failure is non-fatal — retry on next cycle
print('Sync error: $e');
} finally {
_isSyncing = false;
}
}
Future<void> _pushPendingOperations() async {
final pending = await (_db.select(_db.syncOperations)
..where((op) => op.isPending.equals(true))
..orderBy([(op) => OrderingTerm.asc(op.createdAt)]))
.get();
for (final operation in pending) {
try {
final response = await http.post(
Uri.parse('$_apiBaseUrl/sync/push'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_authToken',
},
body: json.encode({
'entityType': operation.entityType,
'entityId': operation.entityId,
'operation': operation.operation,
'payload': json.decode(operation.payload),
}),
);
if (response.statusCode == 200 || response.statusCode == 409) {
// Mark operation as synced (409 = conflict handled by server)
await (_db.update(_db.syncOperations)
..where((op) => op.id.equals(operation.id)))
.write(const SyncOperationsCompanion(
isPending: Value(false),
));
// If conflict, handle the server's resolution
if (response.statusCode == 409) {
final serverVersion = json.decode(response.body);
await _applyServerResolution(
operation.entityType,
serverVersion,
);
}
}
} catch (e) {
// This operation failed — leave it pending, retry next cycle
print('Failed to push operation ${operation.id}: $e');
}
}
}
Future<void> _pullRemoteChanges() async {
// Get the timestamp of our most recent sync
final lastSync = await _getLastSyncTimestamp();
final response = await http.get(
Uri.parse('$_apiBaseUrl/sync/pull?since=${lastSync.toIso8601String()}'),
headers: {'Authorization': 'Bearer $_authToken'},
);
if (response.statusCode != 200) return;
final changes = json.decode(response.body) as List;
for (final change in changes) {
await _applyRemoteChange(change);
}
await _updateLastSyncTimestamp(DateTime.now().toUtc());
}
Future<void> _applyRemoteChange(Map<String, dynamic> change) async {
final entityType = change['entityType'] as String;
final payload = change['payload'] as Map<String, dynamic>;
if (entityType == 'note') {
final existingNote = await (_db.select(_db.notes)
..where((n) => n.id.equals(payload['id'] as String)))
.getSingleOrNull();
if (existingNote == null) {
// New note from server — insert locally
await _db.into(_db.notes).insertOnConflictUpdate(
NotesCompanion.insert(
id: payload['id'] as String,
title: payload['title'] as String,
content: payload['content'] as String,
userId: payload['userId'] as String,
isDeleted: Value(payload['isDeleted'] as bool),
createdAt: DateTime.parse(payload['createdAt'] as String),
updatedAt: DateTime.parse(payload['updatedAt'] as String),
syncedAt: Value(DateTime.now().toUtc()),
),
);
} else {
// Existing note — apply conflict resolution
await _resolveConflict(existingNote, payload);
}
}
}
Future<void> _resolveConflict(
Note local,
Map<String, dynamic> remote,
) async {
final remoteUpdatedAt = DateTime.parse(remote['updatedAt'] as String);
// Last-write-wins: most recent update takes precedence
if (remoteUpdatedAt.isAfter(local.updatedAt) &&
local.syncedAt != null) {
// Remote is newer and local has been synced — apply remote
await (_db.update(_db.notes)
..where((n) => n.id.equals(local.id)))
.write(NotesCompanion(
title: Value(remote['title'] as String),
content: Value(remote['content'] as String),
isDeleted: Value(remote['isDeleted'] as bool),
updatedAt: Value(remoteUpdatedAt),
syncedAt: Value(DateTime.now().toUtc()),
));
}
// If local is newer or has unsynced changes — local wins,
// the push cycle will send local version to server
}
Future<DateTime> _getLastSyncTimestamp() async {
// Read from a settings table or shared preferences
// Defaulting to 24 hours ago for initial implementation
return DateTime.now().toUtc().subtract(const Duration(hours: 24));
}
Future<void> _updateLastSyncTimestamp(DateTime timestamp) async {
// Persist to settings table or shared preferences
}
Future<void> _applyServerResolution(
String entityType,
Map<String, dynamic> serverVersion,
) async {
await _applyRemoteChange({
'entityType': entityType,
'payload': serverVersion,
});
}
}
Step 4 - Conflict Resolution Strategies
The hardest problem in local-first software is what to do when two devices edit the same data while offline. Three strategies cover most use cases:
Last-Write-Wins (LWW)
The simplest strategy, whichever update has the most recent timestamp wins. Appropriate for most non-collaborative apps, notes, tasks, settings, profiles.
// LWW implemented in _resolveConflict above
// Remote wins if remote.updatedAt > local.updatedAt AND local has been synced
// Local wins if local has unsynced changes (local is newer from user's perspective)
Operational Transformation (OT) / CRDTs
For collaborative text editing, multiple users editing the same document simultaneously, simple LWW produces lossy merges. CRDTs (Conflict-free Replicated Data Types) are data structures that merge automatically and correctly under all network conditions:
// Using the crdt package for CRDT-based sync
// pubspec.yaml: crdt: ^3.0.0
import 'package:crdt/crdt.dart';
// A CRDT map that merges without conflicts
final crdtStore = CrdtMap<String, String>(
nodeId: deviceId, // unique per device
);
// Write locally
crdtStore['note:${note.id}:title'] = newTitle;
// Merge remote state — always safe, never loses data
crdtStore.merge(remoteState);
Field-Level Merge
For structured data where different fields may be edited independently on different devices, merge at the field level rather than the record level:
Future<void> _fieldLevelMerge(Note local, Map<String, dynamic> remote) async {
final remoteUpdatedAt = DateTime.parse(remote['updatedAt'] as String);
// Apply each field independently based on which is newer
final update = NotesCompanion(
// Title: take the more recently updated version
title: remoteUpdatedAt.isAfter(local.updatedAt)
? Value(remote['title'] as String)
: Value(local.title),
// Content: same logic
content: remoteUpdatedAt.isAfter(local.updatedAt)
? Value(remote['content'] as String)
: Value(local.content),
syncedAt: Value(DateTime.now().toUtc()),
);
await (_db.update(_db.notes)..where((n) => n.id.equals(local.id)))
.write(update);
}
Step 5 - Displaying Sync Status in the UI
Users need to know when data is unsynced, especially in unreliable network conditions. Surface sync state through reactive streams:
// lib/widgets/sync_status_indicator.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SyncStatusIndicator extends ConsumerWidget {
const SyncStatusIndicator({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pendingCount = ref.watch(pendingSyncCountProvider);
return pendingCount.when(
data: (count) {
if (count == 0) {
return const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_done, color: Colors.green, size: 16),
SizedBox(width: 4),
Text('Synced', style: TextStyle(fontSize: 12, color: Colors.green)),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 4),
Text(
'$count pending',
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
],
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const Icon(Icons.cloud_off, color: Colors.red, size: 16),
);
}
}
Step 6 - Schema Migration with Drift
As your app evolves, database schemas change. Drift handles migrations with versioned upgrade callbacks:
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
},
onUpgrade: (m, from, to) async {
if (from < 2) {
// Version 2: add tags column to notes
await m.addColumn(_db.notes, _db.notes.tags);
}
if (from < 3) {
// Version 3: add attachments table
await m.createTable(_db.attachments);
}
},
beforeOpen: (details) async {
// Enable WAL mode for better concurrent read performance
await customStatement('PRAGMA journal_mode=WAL');
await customStatement('PRAGMA foreign_keys=ON');
},
);
WAL (Write-Ahead Logging) mode is particularly important for local-first apps, it allows reads and writes to proceed concurrently without blocking each other, which matters when the sync engine is writing remote changes while the UI is reading.
Common Pitfalls to Avoid
Treating sync as a background afterthought. Sync is the most complex part of local-first architecture. Design the sync protocol before writing UI code, the data model, conflict resolution strategy, and sync API contract should be locked before the first widget is built.
Deleting data hard locally. Hard deletes create sync nightmares, a device that was offline when a delete happened will re-create the entity on next sync. Always use soft deletes (isDeleted: true) and let the server handle permanent deletion after all devices have synced the tombstone.
Assuming timestamps are reliable. Device clocks can be wrong, manipulated, or drift over time. Don't rely on device-generated timestamps as the sole conflict resolution mechanism for critical data. Use server-assigned Lamport timestamps or vector clocks for high-stakes collaborative editing.
No offline indicator in the UI. Users who can't see that they're offline and that changes are pending will assume the app is broken. Always surface sync state visibly, a subtle indicator builds trust, no indicator breaks it.
Syncing too much at once. Initial sync for a new device installation can involve thousands of records. Implement paginated pull sync with progress indication, don't block the UI waiting for a full sync to complete before the app is usable.
Conclusion
Local-first software is not a niche architecture for specialized apps. It is the correct default for mobile applications where users expect their tools to work regardless of network conditions. SQLite on the device, reactive UI driven by local data, and an asynchronous sync layer that operates independently of user interaction, this is the architecture that respects both the user's time and the unpredictability of real-world network conditions.
Flutter and Drift give you the building blocks. The sync engine and conflict resolution strategy are the engineering challenge that makes local-first non-trivial, but that investment pays off in an app that works in the subway, the hospital, the airplane, and the basement. Everywhere the user needs it.
Building a collaborative local-first app where multiple users edit the same data simultaneously? CRDTs with the crdt Dart package take conflict resolution from "last-write-wins" to "always-correct-merge", a significant architectural upgrade worth a dedicated follow-up. Drop a comment if that's your scenario.
Top comments (1)
Good guide, and nice to see the Ink & Switch ideals turned concrete. The part people skim is conflict resolution, and the failure that shows up in production usually isn't the merge logic itself, it's the IDs. If your primary keys are SQLite autoincrement, two devices both mint id=42 long before they ever sync, and the merge then picks a winner arbitrarily. Mint a UUID/ULID client-side as the sync identity and keep the rowid strictly local. Pair that with applying each sync batch in a single transaction with idempotent upserts, so a retried batch after a dropped connection doesn't double-apply.
Both are cheap up front and miserable to retrofit once devices are in the field.
(Free one-page 8-point self-check for this class of bug, if useful: dev.to/alfa_ilands/8-checks-that-c... . No pitch.)