Flutter Offline Sync in 2026: Build a Crash-Safe Mobile Data Engine with Drift, Workmanager, and Idempotent APIs

If your mobile app still depends on perfect network conditions, users will feel every dead zone, roaming delay, and flaky captive portal. In 2026, a serious app needs an offline-first sync engine that keeps write operations reliable, resolves conflicts predictably, and recovers after crashes without data loss. In this guide, you will build a production-ready Flutter sync architecture using Drift (SQLite), WAL mode, Workmanager background jobs, and idempotent API contracts, so your app stays fast and trustworthy even when connectivity is messy.

Why offline-first is now a baseline expectation

Users create data on trains, in elevators, and during unstable handoffs between Wi-Fi and mobile data. If your app blocks writes until the network comes back, you create silent data loss risk and broken trust. A better model is:

  • Write immediately to local storage.
  • Queue sync operations with deterministic ordering.
  • Retry in the background with exponential backoff.
  • Handle conflicts explicitly with merge rules.

If you already explored resilient clients in our async retry patterns post, this is the mobile equivalent, but with local durability as the first priority.

Architecture: local-first writes + durable sync queue

Use three tables: notes, sync_queue, and sync_state. Every user action updates notes and appends a queue item inside one transaction. This guarantees that no change exists without a corresponding sync intent.

Core data model

  • notes: business data plus updatedAt, version, and deletedAt (soft delete).
  • sync_queue: operation type, entity id, payload hash, retry count, next run timestamp.
  • sync_state: last successful pull cursor and telemetry counters.

Enable SQLite WAL for crash-safe writes

WAL mode improves concurrent read/write behavior and durability under abrupt app termination. Keep transactions short and explicit.

@DriftDatabase(tables: [Notes, SyncQueue, SyncState])
class AppDb extends _$AppDb {
  AppDb() : super(_openConnection());

  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final file = File(p.join(await getDatabasesPath(), 'app.db'));
    final db = NativeDatabase(file);

    // Crash safety + better read/write concurrency
    await db.customStatement('PRAGMA journal_mode = WAL;');
    await db.customStatement('PRAGMA synchronous = NORMAL;');
    await db.customStatement('PRAGMA foreign_keys = ON;');

    return db;
  });
}

For broader platform reliability patterns, compare with the queue backpressure approach in our Node.js realtime queue guide.

Write path: one transaction, two intents

When a user edits a note, do not call the network directly from the UI layer. Persist first, enqueue sync second, then return success to the user instantly.

Future<void> saveNote(NoteDraft draft) async {
  final now = DateTime.now().toUtc();

  await db.transaction(() async {
    // 1) Upsert local business row
    await into(db.notes).insertOnConflictUpdate(NotesCompanion(
      id: Value(draft.id),
      title: Value(draft.title),
      body: Value(draft.body),
      updatedAt: Value(now),
      version: const Value.absent(), // server-controlled
      deletedAt: const Value(null),
    ));

    // 2) Enqueue idempotent sync operation
    final opId = const Uuid().v7();
    final payload = jsonEncode({
      'id': draft.id,
      'title': draft.title,
      'body': draft.body,
      'clientUpdatedAt': now.toIso8601String(),
    });

    await into(db.syncQueue).insert(SyncQueueCompanion.insert(
      opId: opId,
      entityType: 'note',
      entityId: draft.id,
      opType: 'upsert',
      payload: payload,
      payloadHash: sha256.convert(utf8.encode(payload)).toString(),
      retryCount: const Value(0),
      nextRunAt: Value(now),
      status: const Value('pending'),
    ));
  });
}

Background sync worker with Workmanager

Schedule periodic background jobs and trigger immediate sync on connectivity regain. Each job should process a bounded batch and stop cleanly if OS constraints hit.

Server contract for safe retries

  • Send Idempotency-Key as opId.
  • Use conditional writes via If-Match or explicit baseVersion.
  • Return canonical server row with new version and timestamps.

If your backend runs in containers, the supply-chain hardening in our Docker CI security post helps keep this sync API trustworthy in production.

Conflict resolution strategy

A practical default for notes apps:

  1. If server version equals client base version, apply write.
  2. If both changed, merge per field when possible (title/body independently).
  3. If semantic conflict remains, create a local conflict copy and prompt user.

Avoid blind last-write-wins for business-critical domains. It hides data loss instead of solving it.

Observability and reliability checks

Track these metrics from day one:

  • queue_depth and oldest_pending_age
  • sync_success_rate and p95_sync_latency
  • conflict_rate per entity type
  • retry_exhausted_count

Add trace IDs per operation so mobile and backend logs correlate. For tracing patterns, see our OpenTelemetry-focused .NET API guide.

Production hardening checklist

  • Use WAL mode and keep transactions small.
  • Encrypt sensitive local fields if required by compliance.
  • Implement exponential backoff with jitter for retries.
  • Cap batch size (for example, 50 ops/run) to avoid ANR risk.
  • Dead-letter queue entries after max retries, with user-visible recovery actions.
  • Run chaos tests: airplane mode toggles, process kills, clock skew, duplicate responses.

Final thoughts

An offline-first Flutter app is not just a UX enhancement, it is a reliability contract with your users. Drift gives you local durability, Workmanager gives you resilient background execution, and idempotent APIs keep retries safe. Build this foundation early, and every feature you ship later becomes more robust by default.

FAQ

1) Should I use SQLite or Hive for offline sync-heavy apps?

For conflict-aware sync engines, SQLite is usually the better default because transactions, indexes, and query flexibility matter as the app grows. Hive is lighter for simple key-value use cases.

2) Is last-write-wins ever acceptable?

Yes, for low-risk fields like UI preferences. For user-generated content or business records, pair version checks with merge rules to avoid silent overwrites.

3) How often should background sync run?

Use event-driven triggers first (network regained, app foreground), then periodic fallback jobs. Keep periodic cadence conservative to protect battery.

4) How do I test sync reliability before launch?

Automate failure scenarios: random process kill during transaction, duplicate API responses, partial timeouts, and long offline windows. Validate that no user edits disappear after recovery.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Privacy Policy · Contact · Sitemap

© 7Tech – Programming and Tech Tutorials