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

> Source: <https://dev.to/saqrelfirgany/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no-internet-81g>
> Published: 2026-05-23 07:05:51+00:00

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

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.

``` php
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.

``` js
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](https://github.com/saqrelfirgany), 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.
