{"slug": "offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no", "title": "Offline-First Flutter: How We Built a CRM That Manages 100K+ Leads With No Internet", "summary": "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.", "body_md": "[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.](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcp8075nhij4celrfr649.png)\n\nFor a real-estate sales agent, that moment isn't a glitch. It's a lost lead, and a lost commission.\n\nWhen 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.\n\nA \"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.\n\nHere's the architecture we landed on, and the mistakes worth avoiding.\n\n## The core idea: the local database is the source of truth\n\nThe 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.\n\n``` php\nUI / BLoC  ->  Repository  ->  Local DB (SQLite)  <->  Sync engine  <->  API\n```\n\nEvery 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.\n\n## Pillar 1 — Write locally, queue the intent\n\nWhen an agent edits a lead, we do two things in one transaction: update the local row, and record the *intent* to sync it.\n\n```\nclass SyncOperation {\n  final String id;          // uuid\n  final String entity;      // 'lead', 'call_log', ...\n  final String entityId;\n  final OpType type;        // create | update | delete\n  final Map<String, dynamic> payload;\n  final int localVersion;   // bumped on every local edit\n  final DateTime createdAt;\n\n  const SyncOperation({ /* ... */ });\n}\nFuture<void> updateLead(Lead lead) async {\n  await db.transaction((txn) async {\n    await txn.update('leads', lead.toMap(),\n        where: 'id = ?', whereArgs: [lead.id]);\n\n    await txn.insert('sync_queue',\n        SyncOperation(\n          id: uuid.v4(),\n          entity: 'lead',\n          entityId: lead.id,\n          type: OpType.update,\n          payload: lead.toMap(),\n          localVersion: lead.version + 1,\n          createdAt: DateTime.now(),\n        ).toMap());\n  });\n}\n```\n\nBecause 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.\n\n## Pillar 2 — Drain the queue when connectivity returns\n\nA 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.\n\n``` js\nconnectivity.onStatusChange\n    .where((status) => status.isOnline)\n    .listen((_) => _syncEngine.drain());\nFuture<void> drain() async {\n  if (_isSyncing) return;          // never run two drains at once\n  _isSyncing = true;\n  try {\n    final ops = await _queue.pending(limit: 50);\n    for (final op in ops) {\n      final result = await _push(op);\n      if (result.isConflict) {\n        await _resolveConflict(op, result.serverState);\n      }\n      await _queue.remove(op.id);   // only after success\n    }\n  } finally {\n    _isSyncing = false;\n  }\n}\n```\n\nTwo details that saved us a lot of pain:\n\n-\n**A guard flag**(`_isSyncing`\n\n) so a flaky connection toggling on/off doesn't spawn overlapping syncs that duplicate writes. -\n**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`\n\n) make replays safe.\n\n## Pillar 3 — Resolve conflicts on purpose, not by accident\n\nThe 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.\n\nWe 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:\n\n```\nFuture<void> _resolveConflict(SyncOperation op, Lead serverState) async {\n  // Field-level merge: keep the server's pipeline stage (authoritative,\n  // it drives reporting), keep our locally-edited contact notes.\n  final merged = serverState.copyWith(\n    notes: op.payload['notes'],\n    updatedAt: DateTime.now(),\n  );\n  await leadRepo.upsertLocal(merged);\n  await _queue.enqueueUpdate(merged); // push the merged result back\n}\n```\n\nFor 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.\n\n## What this bought us\n\n- Agents stopped losing leads to \"no internet.\" The pipeline kept moving online or off.\n- The UI got\n*faster*, because reads never blocked on the network. - Sync failures became boring: they just retried.\n\n## Lessons I'd pass on\n\n-\n**Decide offline-first on day one.** Retrofitting it onto a network-coupled app is a rewrite, not a feature. -\n**Make the server idempotent** before you trust replays. Operation IDs are your friend. -\n**Test on real cellular, not office WiFi.** The demo that works at your desk is not the product. -\n**Write the conflict rules down.** Future-you will not remember why stage changes behave differently from notes.\n\nI'm Ahmed (Saqr), a senior Flutter engineer — 22+ production apps, 200K+ users. I write about building mobile apps that actually ship and scale.\n\nIf this was useful, follow me here and on [GitHub](https://github.com/saqrelfirgany), where I maintain an open-source **Flutter Enterprise Template** used by 100+ developers.\n\nWhat's your approach to offline sync in Flutter? I'd love to hear it in the comments.", "url": "https://wpnews.pro/news/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no", "canonical_source": "https://dev.to/saqrelfirgany/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no-internet-81g", "published_at": "2026-05-23 07:05:51+00:00", "updated_at": "2026-05-23 07:33:08.194790+00:00", "lang": "en", "topics": ["enterprise-software", "developer-tools", "data", "products", "startups"], "entities": ["AM Live CRM", "Aqarmap", "Flutter", "SQLite"], "alternates": {"html": "https://wpnews.pro/news/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no", "markdown": "https://wpnews.pro/news/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no.md", "text": "https://wpnews.pro/news/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no.txt", "jsonld": "https://wpnews.pro/news/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no.jsonld"}}