Most apps quietly assume the network is always there. Then a real user walks into a basement, a half-built apartment tower, or an elevator — and the app falls apart.
For a real-estate sales agent, that moment isn't a glitch. It's a lost lead, and a lost commission.
When we built the AM Live CRM at Aqarmap (Egypt's largest property platform), our field agents were managing 100,000+ leads a month — and a huge chunk of their day happened exactly in those dead zones: new developments, underground parking, remote plots with one bar of signal.
A "cache the last response" approach wasn't enough. We needed the app to be fully usable with zero connectivity — create a lead, move it through the pipeline, log a call — and have all of it sync cleanly the moment the network came back.
Here's the architecture we landed on, and the mistakes worth avoiding.
The core idea: the local database is the source of truth
The biggest mental shift in offline-first is this: the UI never talks to the network directly. It talks to the local database. The network is just a background process that keeps the local store and the server eventually consistent.
UI / BLoC -> Repository -> Local DB (SQLite) <-> Sync engine <-> API
Every read comes from SQLite. Every write goes to SQLite first, then gets queued for the server. The user never waits on a request, and never sees a spinner that depends on signal.
Pillar 1 — Write locally, queue the intent
When an agent edits a lead, we do two things in one transaction: update the local row, and record the intent to sync it.
class SyncOperation {
final String id; // uuid
final String entity; // 'lead', 'call_log', ...
final String entityId;
final OpType type; // create | update | delete
final Map<String, dynamic> payload;
final int localVersion; // bumped on every local edit
final DateTime createdAt;
const SyncOperation({ /* ... */ });
}
Future<void> updateLead(Lead lead) async {
await db.transaction((txn) async {
await txn.update('leads', lead.toMap(),
where: 'id = ?', whereArgs: [lead.id]);
await txn.insert('sync_queue',
SyncOperation(
id: uuid.v4(),
entity: 'lead',
entityId: lead.id,
type: OpType.update,
payload: lead.toMap(),
localVersion: lead.version + 1,
createdAt: DateTime.now(),
).toMap());
});
}
Because the row and the queue entry are written in the same transaction, you can never end up in a state where the UI shows a change that will never be synced.
Pillar 2 — Drain the queue when connectivity returns
A single listener watches connectivity and kicks off a drain. The drain processes operations in order, one entity at a time, and only removes an operation from the queue after the server confirms it.
connectivity.onStatusChange
.where((status) => status.isOnline)
.listen((_) => _syncEngine.drain());
Future<void> drain() async {
if (_isSyncing) return; // never run two drains at once
_isSyncing = true;
try {
final ops = await _queue.pending(limit: 50);
for (final op in ops) {
final result = await _push(op);
if (result.isConflict) {
await _resolveConflict(op, result.serverState);
}
await _queue.remove(op.id); // only after success
}
} finally {
_isSyncing = false;
}
}
Two details that saved us a lot of pain:
-
A guard flag (
_isSyncing) so a flaky connection toggling on/off doesn't spawn overlapping syncs that duplicate writes. -
Remove-after-confirm. If the app dies mid-sync, the operation is still in the queue and simply replays next time. Idempotent server endpoints (keyed by the operation
id) make replays safe.
Pillar 3 — Resolve conflicts on purpose, not by accident
The dangerous case: an agent edits a lead offline while a colleague edits the same lead on the server. If you blindly push, you silently overwrite their work.
We versioned every record. When the server reports a newer version than the one our operation was based on, we don't guess — we run an explicit strategy:
Future<void> _resolveConflict(SyncOperation op, Lead serverState) async {
// Field-level merge: keep the server's pipeline stage (authoritative,
// it drives reporting), keep our locally-edited contact notes.
final merged = serverState.copyWith(
notes: op.payload['notes'],
updatedAt: DateTime.now(),
);
await leadRepo.upsertLocal(merged);
await _queue.enqueueUpdate(merged); // push the merged result back
}
For some fields last-write-wins is fine. For others (like the 5-stage pipeline that feeds management reporting) the server stays authoritative. The point is that the rule is a decision you document, not an emergent behavior you discover in production.
What this bought us
- Agents stopped losing leads to "no internet." The pipeline kept moving online or off.
- The UI got faster, because reads never blocked on the network.
- Sync failures became boring: they just retried.
Lessons I'd pass on
- Decide offline-first on day one. Retrofitting it onto a network-coupled app is a rewrite, not a feature.
- Make the server idempotent before you trust replays. Operation IDs are your friend.
- Test on real cellular, not office WiFi. The demo that works at your desk is not the product.
- Write the conflict rules down. Future-you will not remember why stage changes behave differently from notes.
I'm Ahmed (Saqr), a senior Flutter engineer — 22+ production apps, 200K+ users. I write about building mobile apps that actually ship and scale.
If this was useful, follow me here and on GitHub, where I maintain an open-source Flutter Enterprise Template used by 100+ developers.
What's your approach to offline sync in Flutter? I'd love to hear it in the comments.
Top comments (0)