Lesson 26 of 77 advanced

Offline-First Architecture & Sync

The Most Differentiating Skill for Senior Flutter Engineers

Open interactive version (quiz + challenge)

Real-world analogy

Offline-first is like designing a doctor who can treat patients without internet access. The doctor writes everything in a physical chart (local DB). When internet returns, the clinic syncs all charts to the central hospital system. If two doctors updated the same patient record offline, the hospital needs a rule for merging: last update wins, or a nurse manually reconciles — that's conflict resolution. Most apps assume broadband — offline-first apps assume the connection is always about to drop.

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

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. 1. SyncOperation is a Floor entity — a DB table acts as the sync queue, surviving app restarts
  2. 2. UUID as operation id ensures the server can detect and ignore duplicate sync requests for idempotency
  3. 3. saveSurveyResponse writes to local DB AND enqueues for sync — never lose data
  4. 4. UI reads from the local DB stream — never directly from API — guaranteed offline consistency
  5. 5. syncPendingResponses processes the queue sequentially — idempotent API calls make retries safe
  6. 6. ConflictException triggers server-wins strategy — server version overwrites local for audit compliance
  7. 7. Retry cap of 5 prevents infinite loops on permanent server-side failures
  8. 8. SyncWorker listens to connectivity changes — sync triggers immediately on reconnect
  9. 9. _checkReachability does a real HTTP ping — not just interface detection from connectivity_plus
  10. 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?
This looks like offline support but has a fundamental offline-first architecture flaw.
Show answer
The UI calls getSurveys() which tries the network first — this is network-first, not offline-first. Problems: (1) Cold start always waits for network, making the app feel slow even when cached data is available. (2) If the network succeeds, the UI only sees the returned list, not a live DB stream — subsequent offline reads and writes are not reflected reactively. (3) There is no sync queue — any local mutations are not tracked for upload. Fix: Return a DB stream via watchAll() so the UI always renders from local data. Have a separate sync layer that fetches from API and writes to DB. Add a sync queue for outgoing mutations. The UI should never wait for the network to render.

Explain like I'm 5

Imagine you are a doctor visiting patients in a village with no phone signal. You write everything in your notepad (local DB). When you drive back to town and get signal, your assistant (sync worker) calls the hospital and reads out all your notes (sync queue). If another doctor visited the same patient and wrote different notes, someone has to decide whose notes to keep — that is conflict resolution. Offline-first means you never stop writing just because you lost signal.

Fun fact

The most famous offline-first success story is Google Docs. When you lose internet mid-edit, your typing continues uninterrupted. Docs uses Operational Transformation (OT) — a form of CRDT — to merge everyone's offline edits without conflicts. The same core technique powers Figma's real-time collaboration. Understanding this pattern puts you in the same engineering conversation as Google and Figma architects.

Hands-on challenge

Design the full sync architecture for an NFC asset recovery app where field engineers may work offline for hours: (1) sketch the SyncQueue DB table schema, (2) describe the SyncWorker trigger conditions, (3) explain your conflict resolution strategy for two engineers who scanned the same asset offline, (4) describe how delta sync works when the engineer comes back online, (5) explain how you'd surface sync errors to the user without disrupting their workflow.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery