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 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.
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.
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.
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.
Conflicts happen when the same record is modified on two devices before syncing. The three common strategies:
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.
The user should always know the state of their data. We show four states:
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.
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.
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
// 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});
}
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.
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.
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