Flutter Offline‑First Architecture: A Practical Guide to Reliable, Fast Apps

A practical Flutter offline‑first guide: architecture, caching, sync, conflict resolution, background tasks, security, and production tips with code.

ASOasis
8 min read
Flutter Offline‑First Architecture: A Practical Guide to Reliable, Fast Apps

Image used for representation purposes only.

Why Offline‑First Matters

Mobile networks are flaky, batteries die, tunnels exist, and users travel. An offline‑first Flutter app treats connectivity as a luxury, not a prerequisite. It prioritizes fast local reads and writes, then synchronizes in the background. The result: lower latency, fewer failures, higher retention, and happier users.

This guide distills a practical architecture you can adapt to most Flutter apps—whether you’re building notes, orders, field data collection, or social features.

Core Principles

  • Single source of truth lives on device. UI consumes local state; the network is an enhancement layer.
  • Deterministic data flow. Every mutation has a clear lifecycle from draft → queued → syncing → resolved.
  • Idempotency everywhere. Retrying must not duplicate side effects on the server.
  • Versioning and timestamps. Conflicts are normal; resolve them predictably.
  • Sync is additive. The app must be useful without a backend round‑trip.

High‑Level Architecture

Think in layers and contracts, not packages and screens.

  • Presentation: Widgets + state management (Bloc/Cubit, Riverpod, ValueNotifier, or MVVM).
  • Domain: Entities, value objects, use cases; platform‑agnostic business rules.
  • Data: Repositories expose domain‑friendly methods and hide persistence and I/O details.
  • Data sources: Local (database, key‑value) and Remote (REST/GraphQL/Firebase).
  • Sync engine: Batched push/pull, conflict resolution, backoff, and metrics.
  • Connectivity and reachability: Detect network and internet availability separately.

A simple mental model:

UI → ViewModel/Bloc → UseCases → Repository → { LocalDataSource ↔ Database,
                                              RemoteDataSource ↔ API }
                           └─────────── Sync Engine (scheduler + queue)

Technology Choices (Flutter‑Friendly)

  • Databases
    • Drift (SQLite + code‑gen, typed, migrations)
    • Isar or ObjectBox (NoSQL/object store, very fast, good for mobile)
    • Hive (lightweight boxes; great for caches, settings)
  • Networking
    • Dio (interceptors, retries, caching headers)
    • Chopper/Retrofit (code‑gen for clients)
  • Connectivity
    • connectivity_plus (network status) + a small reachability check (HEAD to your API) for truth
  • Background work
    • workmanager (best‑effort periodic tasks; iOS is constrained), foreground services on Android for long operations
  • Secure storage
    • flutter_secure_storage for tokens/keys
  • Serialization
    • json_serializable or freezed for immutable models and sealed results

Pick the smallest set that solves your problem; fewer moving parts means fewer sync bugs.

Data Modeling for Sync

Add fields that make synchronization predictable:

  • localId: A UUID v4 generated on device, stable before server acknowledgement.
  • remoteId: Nullable until the server returns a canonical ID.
  • updatedAt: Device timestamp; use serverUpdatedAt when known.
  • version: Monotonic integer or ETag for optimistic concurrency.
  • syncStatus: {dirty, syncing, synced, failed, deleted}
  • tombstone: Boolean for soft‑deletes so you can propagate deletions later.

Example Dart entity (domain layer):

enum SyncStatus { dirty, syncing, synced, failed, deleted }

class Note {
  final String localId; // always present
  final String? remoteId; // set after server acknowledgement
  final String title;
  final String body;
  final DateTime updatedAt; // device time of last local change
  final int version; // server-assigned or incremented on merge
  final SyncStatus syncStatus;

  Note({
    required this.localId,
    this.remoteId,
    required this.title,
    required this.body,
    required this.updatedAt,
    required this.version,
    required this.syncStatus,
  });

  Note markDirty(DateTime now) => Note(
    localId: localId,
    remoteId: remoteId,
    title: title,
    body: body,
    updatedAt: now,
    version: version,
    syncStatus: SyncStatus.dirty,
  );
}

Repository Pattern (Local‑First)

The repository returns local data immediately and orchestrates remote refresh in the background. UI never blocks on the network.

abstract class NoteRepository {
  Stream<List<Note>> watchAll(); // fast, local stream
  Future<Note> getById(String localId); // local first
  Future<void> upsert(Note note); // mark dirty + persist
  Future<void> delete(String localId); // mark deleted (tombstone)
  Future<void> sync(); // push dirty, pull updates
}

Local and remote are injected into the repository:

class NoteRepositoryImpl implements NoteRepository {
  final LocalDataSource local;
  final RemoteDataSource remote;
  final SyncScheduler scheduler;

  NoteRepositoryImpl(this.local, this.remote, this.scheduler);

  @override
  Stream<List<Note>> watchAll() => local.watchNotes();

  @override
  Future<Note> getById(String id) async {
    final n = await local.get(id);
    // Fire-and-forget background refresh (SWR)
    scheduler.revalidate(() async {
      final server = await remote.getByRemoteId(n.remoteId);
      await local.mergeServer(server);
    });
    return n;
  }

  @override
  Future<void> upsert(Note note) async {
    await local.save(note.markDirty(DateTime.now()));
    scheduler.scheduleSync();
  }

  @override
  Future<void> delete(String id) async {
    await local.markDeleted(id);
    scheduler.scheduleSync();
  }

  @override
  Future<void> sync() async {
    await _pushDirty();
    await _pullDelta();
  }

  Future<void> _pushDirty() async { /* see sync section */ }
  Future<void> _pullDelta() async { /* see sync section */ }
}

Local Persistence Example (Drift)

Define a typed schema and migrations so you can evolve safely.

// drift table
class Notes extends Table {
  TextColumn get localId => text()();
  TextColumn get remoteId => text().nullable()();
  TextColumn get title => text()();
  TextColumn get body => text()();
  IntColumn get version => integer().withDefault(const Constant(0))();
  IntColumn get syncStatus => integer()(); // map enum index
  BoolColumn get tombstone => boolean().withDefault(const Constant(false))();
  DateTimeColumn get updatedAt => dateTime()();

  @override
  Set<Column> get primaryKey => {localId};
}

For security, consider SQLCipher via a plugin if you store sensitive data. Store tokens/keys in the secure enclave/keystore using flutter_secure_storage.

Connectivity And Reachability

  • connectivity_plus tells you if the device is on Wi‑Fi/cellular but not if the internet is reachable.
  • Add a cheap HEAD/GET to your API health endpoint in the sync scheduler. Cache the result briefly.
  • Use exponential backoff and jitter for retries: 1s, 2s, 4s, 8s (± random 20%).
Future<bool> isReachable(RemoteDataSource api) async {
  try {
    await api.ping(); // HEAD /
    return true;
  } catch (_) {
    return false;
  }
}

Synchronization: Push, Then Pull

A predictable two‑phase flow keeps things simple.

  1. Push dirty queue
  • Select rows where syncStatus ∈ {dirty, deleted}.
  • Send requests with optimistic concurrency control.
  • If success: update remoteId (if new), version, serverUpdatedAt; set syncStatus = synced.
  • If 409/412 conflict: fetch server, merge, increment version, retry or mark failed for manual resolution.
  1. Pull delta
  • Request changes since lastSuccessfulPull using updatedAfter, ETag/If‑None‑Match, or cursor tokens.
  • Upsert into local DB. Convert remote deletes into tombstones locally.
  • Update lastSuccessfulPull on success only.

Idempotency and Concurrency Control

  • Assign an idempotency key per mutation (e.g., localId + version) so server retries don’t duplicate.
  • Use If‑Match with entity version or ETag. Server rejects if versions diverge.

Conflict Resolution Strategies

  • LWW (last‑writer‑wins): simplest; prefer highest serverUpdatedAt.
  • Field‑level merge: combine non‑overlapping fields; prefer newer per‑field timestamp.
  • Manual: mark failed and surface a non‑blocking UI to let users choose.
  • CRDT: powerful for collaborative editing; heavier to implement.

A simple merger:

Note merge(Note local, Note remote) {
  // Prefer newer updates; keep localId stable
  final newer = remote.updatedAt.isAfter(local.updatedAt) ? remote : local;
  return Note(
    localId: local.localId,
    remoteId: remote.remoteId ?? local.remoteId,
    title: newer.title,
    body: newer.body,
    updatedAt: newer.updatedAt,
    version: remote.version, // align to server on success
    syncStatus: SyncStatus.synced,
  );
}

Optimistic UI With Rollbacks

Update UI instantly; reconcile later.

Future<void> renameNote(NoteRepository repo, Note n, String title) async {
  final optimistic = n.copyWith(title: title).markDirty(DateTime.now());
  await repo.upsert(optimistic); // UI shows new title
  // Background sync may roll forward (success) or roll back (conflict)
}

If the server rejects, either auto‑merge and notify the user, or present a diff. Keep the UI resilient: no spinners for common flows.

Background Sync And Scheduling

  • Use workmanager to schedule best‑effort periodic tasks (e.g., every 15 minutes). On iOS, the OS decides when tasks run; design for eventuality, not precision.
  • Trigger opportunistic sync on app resume, foreground, and after connectivity regained.
  • For large pushes, prefer foreground services on Android or chunked uploads when the app is active.
void callbackDispatcher() {
  Workmanager().executeTask((task, input) async {
    final repo = locator<NoteRepository>();
    await repo.sync();
    return Future.value(true);
  });
}

Future<void> initBackgroundSync() async {
  await Workmanager().initialize(callbackDispatcher);
  await Workmanager().registerPeriodicTask('sync-notes', 'syncTask',
      frequency: const Duration(minutes: 15), existingWorkPolicy: ExistingWorkPolicy.keep);
}

Caching Patterns That Work

  • Stale‑While‑Revalidate (SWR): Show cached data immediately; refresh in background; update stream.
  • Write‑through cache: Every write hits local first, then remote.
  • Time‑to‑live (TTL) heuristics: Optional; mostly rely on updatedAt/version.

Error Handling And Observability

  • Classify errors: transient (timeouts), permanent (validation), and conflicts.
  • Persist failure metadata (lastError, retryAfter) to avoid hot loops.
  • Emit structured logs for each sync step. Add counters: queued, pushed, merged, conflicted.
  • Expose a debug screen showing queue depth and last sync time.

Migrations And Backward Compatibility

  • Plan schema migrations from day zero. Drift code‑gen helps keep them deterministic.
  • Use forward‑compatible wire formats (ignore unknown fields).
  • Stagger rollouts: handle older clients gracefully; don’t hard‑fail on new fields.

Security And Privacy

  • Encrypt at rest if storing PII (SQLCipher, platform keychain for keys).
  • Encrypt in transit (TLS). Pin certificates or validate hosts if your threat model requires it.
  • Minimize sensitive data stored offline; expire or redact when no longer needed.
  • Keep access tokens in secure storage; refresh them before sync to avoid mid‑flight failures.

Testing Strategy

  • Unit test repositories with in‑memory local sources and fake remote endpoints.
  • Property tests for mergers and conflict logic.
  • Integration tests: flip connectivity, inject clock skew, simulate 409 conflicts, and assert eventual consistency.
  • Golden tests for UI states: dirty rows, syncing spinners, conflict banners.

Example: Minimal Sync Implementation Sketch

class SyncEngine {
  final LocalDataSource local;
  final RemoteDataSource remote;
  final Reachability reach;

  Future<void> runOnce() async {
    if (!await reach.isOnline()) return;
    await _push();
    await _pull();
  }

  Future<void> _push() async {
    final dirty = await local.selectDirty(limit: 100);
    for (final n in dirty) {
      try {
        await local.markSyncing(n.localId);
        final res = n.remoteId == null
            ? await remote.create(n)
            : await remote.update(n, ifMatch: n.version);
        await local.applyServerAck(n.localId, res.remoteId, res.version, res.updatedAt);
      } on ConflictException catch (e) {
        final server = await remote.getById(e.remoteId);
        final merged = merge(await local.get(n.localId), server);
        await local.save(merged);
      } catch (e) {
        await local.markFailed(n.localId, e.toString());
      }
    }
  }

  Future<void> _pull() async {
    final since = await local.getLastPullCursor();
    final page = await remote.listUpdatedSince(since);
    for (final server in page.items) {
      await local.mergeServer(server);
    }
    await local.setLastPullCursor(page.nextCursor);
  }
}

Production Checklist

  • Domain entities include localId, remoteId, updatedAt, version, syncStatus, tombstone
  • Repository is local‑first; UI never blocks on network
  • Sync engine implements push‑then‑pull with retries and backoff
  • Conflict resolution policy documented and tested
  • Background sync registered; also trigger on app resume/connectivity gain
  • Observability: logs, counters, debug screen, crash links
  • Secure storage for tokens; optional DB encryption for PII
  • Schema migrations covered by tests; forward‑compatible wire formats
  • Failure injection tests for airplane mode, timeouts, and 409 conflicts

When Firebase/Firestore Is Enough

Firestore and some GraphQL backends offer built‑in offline caching and delta sync. You still need to design conflict policies and UI feedback, but you can skip most of the plumbing. Evaluate trade‑offs: vendor lock‑in vs. faster delivery.

Conclusion

Offline‑first is a discipline: model sync explicitly, let local state drive the UI, and treat the network as eventually helpful. With a small set of well‑chosen tools—typed storage, a local‑first repository, and a robust sync engine—you can ship a Flutter app that feels instant, survives dead zones, and reconciles safely when the network returns.

Related Posts