Offline-First Architecture in Flutter: A Production Guide
How to build Flutter apps that work seamlessly without internet — covering SQLite sync strategies, conflict resolution with vector clocks, and background sync patterns used in the PUMP fitness app.
Why Offline-First Matters
Users don't wait for loading spinners. In fitness apps, gym basements have terrible connectivity. In field service apps, workers are often in areas with spotty signal. Offline-first isn't a nice-to-have — it's the difference between an app that gets used and one that gets uninstalled. When I built the PUMP fitness app, offline capability was the #1 requirement from day one.
The Architecture
The core pattern is simple: SQLite is the source of truth, not the server. Every user action writes to the local database first, then a background sync engine pushes changes to the server when connectivity is available. The complexity lives in conflict resolution — what happens when the same workout is edited on two devices before they sync?
Setting Up Drift (SQLite) as Source of Truth
I use the drift package (formerly moor) for type-safe SQLite in Flutter. It generates Dart code from table definitions, giving you compile-time query validation. Here's a simplified schema for a workout tracking table:
// database/tables.dart
class Workouts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 200)();
DateTimeColumn get startedAt => dateTime()();
DateTimeColumn get completedAt => dateTime().nullable()();
TextColumn get exercises => text()(); // JSON-encoded
IntColumn get syncVersion => integer().withDefault(const Constant(0))();
TextColumn get vectorClock => text().withDefault(const Constant('{}'))();
BoolColumn get pendingSync => boolean().withDefault(const Constant(true))();
}Conflict Resolution with Vector Clocks
Timestamps break when device clocks drift. Vector clocks solve this by tracking causal ordering of events across devices. Each device increments its own counter in the clock on every write. When syncing, the server compares vector clocks: if one strictly dominates, it wins; if they're concurrent (both have changes the other doesn't), the server presents a merge conflict. In practice, with single-user apps like PUMP, true conflicts are rare (<0.3% of syncs).
Background Sync Engine
The sync engine runs as a Dart isolate triggered by connectivity changes (via the connectivity_plus package) and periodic timers. It batches pending changes, sends them as a single HTTP request, and applies the server's response (which may include changes from other devices) in a single SQLite transaction. This atomic approach prevents partial sync states that could corrupt the local database.
Results from Production
In the PUMP app, this architecture handles 15,000+ monthly active users with zero data loss incidents. Sync conflicts are auto-resolved in 99.7% of cases. The remaining 0.3% present a clear merge UI. Average sync time is under 2 seconds even for users with weeks of offline data. If you're building a mobile app that needs offline support, this pattern is battle-tested.