cd /news/enterprise-software/offline-first-flutter-how-we-built-a… · home topics enterprise-software article
[ARTICLE · art-10894] src=dev.to ↗ pub= topic=enterprise-software verified=true sentiment=· neutral

Offline-First Flutter: How We Built a CRM That Manages 100K+ Leads With No Internet

The article describes how Aqarmap, Egypt's largest property platform, built an offline-first CRM for real estate agents managing over 100,000 leads monthly. The architecture ensures the app remains fully functional without internet by having the UI interact exclusively with a local SQLite database, with a sync engine queuing all writes and processing them in order once connectivity is restored. Key features include transactional writes to prevent sync failures, a single listener to avoid overlapping syncs, and version-based conflict resolution that merges field-level changes rather than blindly overwriting data.

read5 min views16 publishedMay 23, 2026

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 operationid

) 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.

── more in #enterprise-software 4 stories · sorted by recency
── more on @am live crm 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/offline-first-flutte…] indexed:0 read:5min 2026-05-23 ·