← Flutter Reference

How to Build Offline-First Flutter Apps

Data & Sync · Advanced

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

An offline-first app does not just "work without internet" — it treats the local database as the single source of truth and syncs to the server as a background concern. Users read and write against local data with zero latency, and the sync engine reconciles changes when connectivity returns.

At iStoria, we serve 5M+ users across regions with unreliable connectivity. An offline-first architecture is not a luxury; it is the reason the app feels instant. We use PowerSync on top of Drift to handle the sync engine, but the patterns in this guide apply regardless of your sync technology.

This is a how-to/template guide — the specific pattern for building offline-first data flows. For the full story of why we chose this architecture and what it took to scale it at iStoria, read the offline-first sync case study.

The Mental Model: Local Is Truth

The fundamental shift in offline-first architecture is this: the local database is the source of truth, not the server. Every read comes from the local database (instant, no network round-trip). Every write goes to the local database first (also instant), then a sync engine propagates it to the server asynchronously.

This is the opposite of the typical online-first pattern where the app calls the API, waits for the response, and caches the result. In that pattern, the cache is a performance optimization layered on top of network calls. In offline-first, the network is a sync concern layered on top of local operations.

The Three Components

1. Local database — the source of truth. At iStoria, this is Drift (formerly Moor), a reactive SQLite wrapper for Flutter. Every read is a stream from the local database. Every write is a local insert/update/delete.

2. Sync engine — propagates local changes to the server and pulls server changes down to the local database. We use PowerSync, which provides a managed sync layer that handles the wire protocol, conflict resolution, and reconnection. You can build your own with a change log + polling/SSE, but that is a significant engineering effort.

3. Connectivity awareness — the UI needs to reflect sync status (synced, syncing, pending changes, conflict). The app needs to handle the transitions gracefully — queue writes when offline, resume sync when online, and show the user what is happening.

Reading: Streams, Not Futures

In an offline-first app, reads are reactive streams, not one-shot futures. When you write to the local database, the stream emits a new value and the UI updates automatically. When the sync engine pulls changes from the server, the stream emits again. The UI does not need to know whether the change was local or remote — it just reacts to the stream.

This is why Drift's watch() method and PowerSync's reactive queries are so powerful. The UI subscribes to a query result, and the database notifies it whenever the result set changes. No manual refresh, no pull-to-refresh, no invalidation logic.

Writing: Optimistic Local First

Writes follow an optimistic pattern:

1. Write to the local database immediately (the UI reflects the change via the stream). 2. The sync engine detects the local change and queues it for upload. 3. If online, the change is pushed to the server. If offline, it sits in the queue. 4. When connectivity returns, the queue is flushed.

If the server rejects the change (validation error, conflict), the sync engine triggers a conflict resolution callback. Your app decides what to do: last-write-wins, merge, or prompt the user. At iStoria, we use last-write-wins for most data and field-level merging for collaborative content.

Conflict Resolution

Conflicts happen when the same record is modified on two devices before syncing. The three common strategies:

  • Last-write-wins (LWW): The change with the latest timestamp wins. Simple, good enough for most data. Requires clocks to be roughly synchronized.
  • Field-level merge: Non-overlapping field changes are merged. Requires a sync engine that understands field-level changes, not just whole-record overwrites.
  • Custom resolution: For high-value conflicts, prompt the user or apply domain-specific logic.

Most apps use LWW for 95% of data and custom resolution for the handful of cases where it matters. Do not over-engineer conflict resolution until you have a real conflict problem.

Sync Status in the UI

The user should always know the state of their data. We show four states:

  • Synced — all local changes have been confirmed by the server.
  • Syncing — changes are being uploaded or downloaded.
  • Pending — local changes are queued but not yet synced (offline or in queue).
  • Error/Conflict — the last sync failed and needs attention.

This is not just a nice-to-have. On an app used in low-connectivity regions, users will wonder whether their progress was saved. A clear sync indicator builds trust.

Testing Offline Behavior

Test the offline/online transitions explicitly. In integration tests, toggle connectivity and verify that writes are queued, reads still work, and sync resumes correctly. The biggest bugs in offline-first apps happen at the transition points — going offline mid-sync, coming back online with a large queue, or handling partial sync failures.

Recommended folder structure

lib/
├── core/
│   ├── database/
│   │   ├── app_database.dart          # Drift database definition
│   │   └── tables/
│   │       ├── lessons.dart
│   │       └── sync_queue.dart        # pending local changes
│   ├── sync/
│   │   ├── sync_engine.dart           # PowerSync / custom sync coordinator
│   │   ├── connectivity_manager.dart
│   │   └── conflict_resolver.dart
│   └── error/
└── features/
    └── lesson/
        ├── domain/
        │   ├── entities/lesson.dart
        │   └── repositories/lesson_repository.dart
        ├── data/
        │   ├── datasources/
        │   │   └── lesson_local_datasource.dart   # Drift queries
        │   └── repositories/
        │       └── lesson_repository_impl.dart     # reads stream, writes local
        └── presentation/
            ├── cubit/
            │   ├── lesson_cubit.dart
            │   └── lesson_state.dart               # includes syncStatus
            └── widgets/
                └── sync_status_badge.dart

Code example

// domain/repositories/lesson_repository.dart — offline-first contract
abstract class LessonRepository {
  /// Reactive stream from the LOCAL database — instant, no network wait.
  /// Updates automatically when sync pulls new data from the server.
  Stream<List<Lesson>> watchLessons(String courseId);

  /// Optimistic write: saves to local DB first, sync engine handles upload.
  Future<Either<Failure, Lesson>> markComplete(String lessonId);

  /// Forces a sync cycle (used by "refresh" gestures and connectivity restore).
  Future<Either<Failure, void>> syncNow();
}

// data/repositories/lesson_repository_impl.dart
class LessonRepositoryImpl implements LessonRepository {
  final LessonLocalDatasource localDatasource;
  final SyncEngine syncEngine;

  LessonRepositoryImpl({
    required this.localDatasource,
    required this.syncEngine,
  });

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    // This stream emits from the local Drift database.
    // When syncEngine pulls server changes and writes them locally,
    // Drift's watch() fires and the UI gets the new data automatically.
    return localDatasource
        .watchLessonsByCourse(courseId)
        .map((rows) => rows.map(_toEntity).toList());
  }

  @override
  Future<Either<Failure, Lesson>> markComplete(String lessonId) async {
    try {
      // 1. Write to local DB immediately (optimistic — UI reacts via stream)
      await localDatasource.updateLessonStatus(
        lessonId,
        isCompleted: true,
        pendingSync: true,   // marks the row for the sync queue
      );

      // 2. Sync engine picks up pendingSync rows and uploads them.
      // If offline, they sit in the queue until connectivity returns.
      unawaited(syncEngine.enqueueUpload(LessonCompletedEvent(lessonId)));

      // 3. Return success — the user sees their change instantly
      final updated = await localDatasource.getLessonById(lessonId);
      return Right(_toEntity(updated));
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Future<Either<Failure, void>> syncNow() async {
    try {
      await syncEngine.forceSync();
      return const Right(null);
    } on SyncException catch (e) {
      return Left(SyncFailure(message: e.message));
    }
  }

  Lesson _toEntity(LessonEntry row) => Lesson(
        id: row.id,
        title: row.title,
        durationMinutes: row.durationMinutes,
        isCompleted: row.isCompleted,
        courseId: row.courseId,
      );
}

// presentation/cubit/lesson_state.dart — sync status is part of UI state
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
  final List<Lesson> lessons;
  final SyncStatus syncStatus;    // synced | syncing | pending | error

  LessonLoaded({required this.lessons, required this.syncStatus});
}

Implementation checklist

  • Choose a local database that supports reactive streams (Drift/watch or PowerSync reactive queries).
  • Make every read a stream from the local database — never a network call that the UI waits on.
  • Write to the local database first (optimistic), then enqueue a sync upload — the UI reflects the change immediately.
  • Implement a connectivity manager that pauses the sync queue when offline and flushes it when online.
  • Define a conflict resolution strategy per data type — last-write-wins for most, custom for high-value fields.
  • Show sync status in the UI (synced, syncing, pending, error) so users trust their data is safe.
  • Write integration tests that toggle connectivity mid-operation and verify queued writes resume correctly.

Common mistakes

  • Treating the local database as a cache rather than the source of truth, leading to stale data and cache-invalidation bugs.
  • Using one-shot Future reads instead of reactive streams, so the UI does not update when sync pulls new data.
  • Not showing sync status to users, causing anxiety about whether progress was saved in low-connectivity regions.
  • Over-engineering conflict resolution for every field when last-write-wins handles 95% of cases.
  • Forgetting to test the offline-to-online transition — the most common source of sync bugs.

Related definitions

Related reading

FAQ

What is the difference between offline-first and local-first?

Offline-first means the app works without connectivity but still treats the server as the primary source of truth — the local database is a fast cache with sync. Local-first means the local database IS the primary source of truth permanently; the server is a sync peer, not an authority. Offline-first is more common in client-server apps; local-first is used in collaborative apps. See our offline-first vs. local-first article for the full breakdown.

Do I need PowerSync, or can I build my own sync engine?

You can build your own with a change log table + polling or SSE, but it is significant engineering effort — conflict resolution, reconnection logic, partial sync recovery, and schema migrations all become your problem. PowerSync handles these for you. For a team of 4 engineers serving 5M users, a managed sync layer was the right call. Build your own only if you have a dedicated platform team.

How do I handle conflicts in an offline-first app?

Start with last-write-wins (LWW) for most data — it is simple and handles 95% of cases. For fields where concurrent edits are common (collaborative content), use field-level merging or prompt the user. Design your conflict resolution per data type, not globally. And test conflict scenarios explicitly in integration tests.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work