Offline-First Architecture & Sync
The Most Differentiating Skill for Senior Flutter Engineers
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Offline-first architecture treats local storage as the primary data source. Network is used exclusively for synchronization, not for serving UI. A sync queue (Outbox pattern) stores outgoing changes and processes them when connectivity is restored. Conflict resolution strategies handle cases where the same data is modified in multiple places while offline.
Real-world relevance
An offline-first field survey app for rural healthcare workers: nurses complete patient surveys in remote villages with no connectivity. Each survey answer is written to Floor immediately. A SyncWorker monitors connectivity and when online, processes the sync queue by uploading completed surveys to the server. If two nurses updated the same patient record, the server detects the conflict via version numbers and the app surfaces a merge UI — the nurse's offline work is never silently lost.
Key points
- Why Offline-First Is a Differentiator — Most Flutter devs write apps that assume connectivity. Senior engineers in field apps (surveys, asset management, healthcare) know: the app must be fully functional with zero connectivity. Users in rural areas, basements, or aircraft expect the app to work. Offline-first is not a feature — it's a foundational architecture decision made on day 1.
- The Offline-First Pattern — Core pattern: UI reads exclusively from local DB. When online, sync layer fetches remote changes and writes to local DB and UI updates reactively via DB stream. Outgoing changes are queued locally and synced when connectivity returns. The UI never calls the network directly. Network is just a synchronization mechanism, not the data source.
- Sync Queue (Outbox Pattern) — All mutations (create/update/delete) are first written to a local sync queue (a DB table with status: pending/syncing/synced/failed). A background sync worker processes the queue when online: dequeues the next pending operation, calls the API, marks it synced on success, or marks it failed with retry count on error. This is the Outbox pattern from distributed systems.
- Conflict Resolution Strategies — When the same entity is modified offline by two different users or the same user on two devices, a conflict occurs. Strategies: (1) Last-Write-Wins (LWW): the most recent timestamp wins — simple, loses data. (2) Server-Wins: the server version always overwrites local — user loses offline work. (3) Client-Wins: local always wins — risk of overwriting others work. (4) Manual merge: show the user both versions and ask — correct for critical data. (5) CRDTs: mathematically merge without conflicts — complex but powerful.
- CRDTs — Conflict-Free Replicated Data Types — CRDTs are data structures that guarantee automatic merging without conflicts. A G-Counter CRDT for message read count always produces the correct total by merging two partial counts. A LWW-Register CRDT uses timestamps to resolve writes. CRDTs are used by Figma, Notion, and are the future of offline-first sync. Not trivial to implement, but understanding them signals senior-level systems thinking.
- Connectivity Monitoring — Use the connectivity_plus package to monitor network changes. But connectivity_plus only tells you if a network interface is available, not if the internet is reachable (airplane mode with WiFi connected is a false positive). Add an actual reachability check by pinging a lightweight endpoint like /health. Debounce connectivity events before triggering sync.
- Optimistic UI with Rollback — Show local changes immediately in the UI (optimistic). If the server rejects the change due to conflict or validation error, display an error and revert or offer resolution options. In a survey app, the user should never lose an answer they typed — even if sync fails, the answer stays in local storage and retries indefinitely until synced.
- Delta Sync vs Full Sync — Full sync downloads all server data from scratch. Simple, but expensive at scale. Delta sync tracks a last-synced-at timestamp per entity type. On sync, request only changes since that timestamp: GET /surveys?updated_after=2024-01-15T10:00:00Z. Far more efficient. For deletions, the server must maintain a soft delete with a deletedAt timestamp — hard deletes are invisible to delta sync.
- Sync Engine Design — A production sync engine has: (1) A SyncRepository that tracks last sync timestamps per entity, (2) A SyncWorker that runs periodically, (3) Per-entity sync strategies (full vs delta), (4) Conflict detection via version numbers or ETags, (5) Idempotent API calls (the same sync call can be made multiple times safely), (6) Sync status stream so the UI shows Syncing or Last synced 2 min ago.
- NFC Asset Recovery Scenario — In an NFC asset recovery app, field engineers scan NFC tags on equipment to log status in a basement with zero connectivity. Each scan creates a local asset inspection record. When they return to WiFi range, the sync queue uploads all inspections. If two engineers scanned the same asset in the same hour, the server uses last-write-wins with the server timestamp, and both clients reconcile to the server version on next sync.
- Testing Offline Scenarios — Mock connectivity status in tests using a FakeConnectivityChecker. Test that operations complete without network, sync queue accumulates pending operations, sync triggers on connectivity restore, conflicts are handled by the configured strategy, and the UI shows correct sync status. Never test offline behavior manually — it is too easy to miss edge cases.
Code example
// === Outbox Pattern with Floor ===
@Entity(tableName: 'sync_queue')
class SyncOperation {
@PrimaryKey(autoGenerate: false)
final String id; // UUID — idempotency key
final String entityType; // 'survey_response', 'asset_inspection'
final String entityId;
final String operation; // 'CREATE', 'UPDATE', 'DELETE'
final String payload; // JSON-encoded entity
final String status; // 'PENDING', 'SYNCING', 'SYNCED', 'FAILED'
final int retryCount;
final int createdAtMs;
const SyncOperation({
required this.id, required this.entityType, required this.entityId,
required this.operation, required this.payload,
this.status = 'PENDING', this.retryCount = 0, required this.createdAtMs,
});
}
// Repository: write locally first, enqueue for sync
class SurveyResponseRepository {
final SurveyResponseDao _dao;
final SyncQueueDao _syncQueue;
final SurveyApi _api;
SurveyResponseRepository(this._dao, this._syncQueue, this._api);
Future<void> saveSurveyResponse(SurveyResponse response) async {
await _dao.insert(response.toEntity());
await _syncQueue.enqueue(SyncOperation(
id: response.id,
entityType: 'survey_response',
entityId: response.id,
operation: 'CREATE',
payload: jsonEncode(response.toJson()),
createdAtMs: DateTime.now().millisecondsSinceEpoch,
));
}
// UI always reads from DB — never directly from API
Stream<List<SurveyResponse>> watchResponses(String surveyId) =>
_dao.watchBySurveyId(surveyId).map(
(entities) => entities.map((e) => e.toDomain()).toList());
Future<void> syncPendingResponses() async {
final pending = await _syncQueue.getPending('survey_response');
for (final op in pending) {
try {
await _syncQueue.markSyncing(op.id);
final response = SurveyResponse.fromJson(
jsonDecode(op.payload) as Map<String, dynamic>);
await _api.uploadResponse(response); // Server uses op.id as idempotency key
await _syncQueue.markSynced(op.id);
} on ConflictException catch (e) {
// Server wins strategy — update local with server version
await _dao.insert(e.serverVersion.toEntity());
await _syncQueue.markSynced(op.id);
} catch (e) {
final newCount = op.retryCount + 1;
if (newCount >= 5) {
await _syncQueue.markFailed(op.id, e.toString());
} else {
await _syncQueue.markPendingWithRetry(op.id, newCount);
}
}
}
}
}
// SyncWorker: trigger on connectivity restore
class SyncWorker {
final SurveyResponseRepository _repository;
final Connectivity _connectivity;
StreamSubscription<ConnectivityResult>? _sub;
SyncWorker(this._repository, this._connectivity);
void start() {
_sub = _connectivity.onConnectivityChanged.listen((result) async {
if (result != ConnectivityResult.none) {
final reachable = await _checkReachability();
if (reachable) await _repository.syncPendingResponses();
}
});
}
Future<bool> _checkReachability() async {
try {
final result = await Dio().get('https://api.example.com/health',
options: Options(receiveTimeout: const Duration(seconds: 3)));
return result.statusCode == 200;
} catch (_) { return false; }
}
void dispose() => _sub?.cancel();
}
// Delta sync: fetch only changes
Future<void> pullRemoteChanges(Dio dio, SurveyResponseDao dao, DateTime? lastSync) async {
final response = await dio.get('/survey-responses/delta', queryParameters: {
'updated_after': lastSync?.toIso8601String(),
'include_deleted': true,
});
final data = response.data as Map<String, dynamic>;
final updated = (data['updated'] as List).map((j) => SurveyResponseEntity.fromJson(j)).toList();
final deletedIds = (data['deleted'] as List).cast<String>();
await dao.upsertAll(updated);
await dao.deleteByIds(deletedIds);
}Line-by-line walkthrough
- 1. SyncOperation is a Floor entity — a DB table acts as the sync queue, surviving app restarts
- 2. UUID as operation id ensures the server can detect and ignore duplicate sync requests for idempotency
- 3. saveSurveyResponse writes to local DB AND enqueues for sync — never lose data
- 4. UI reads from the local DB stream — never directly from API — guaranteed offline consistency
- 5. syncPendingResponses processes the queue sequentially — idempotent API calls make retries safe
- 6. ConflictException triggers server-wins strategy — server version overwrites local for audit compliance
- 7. Retry cap of 5 prevents infinite loops on permanent server-side failures
- 8. SyncWorker listens to connectivity changes — sync triggers immediately on reconnect
- 9. _checkReachability does a real HTTP ping — not just interface detection from connectivity_plus
- 10. Delta sync sends lastSyncTime as query param — server returns only changes since that time
Spot the bug
class SurveyRepository {
final Dio _dio;
final SurveyDao _dao;
SurveyRepository(this._dio, this._dao);
Future<List<Survey>> getSurveys() async {
try {
final response = await _dio.get('/surveys');
final surveys = (response.data as List)
.map((j) => Survey.fromJson(j))
.toList();
await _dao.insertAll(surveys.map((s) => s.toEntity()).toList());
return surveys;
} catch (e) {
return await _dao.getAll().then((entities) =>
entities.map((e) => e.toDomain()).toList());
}
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Offline First App Architecture (Offline First Community)
- connectivity_plus Package (pub.dev)
- CRDTs: The Hard Parts — Martin Kleppmann (Martin Kleppmann)
- The Outbox Pattern (microservices.io)
- WorkManager Flutter Plugin (pub.dev)