When your Flutter app loses its connection, does the user hit a "no connection" screen, or does it keep working without interruption? Offline-first architecture keeps data on the device first and pushes the network to the background, and in Flutter the most mature tool for this is drift — with type-safe queries, compile-time schema validation, and migration support. In this guide you'll build a real local database with drift, write a schema migration, cover write-queue (outbox) and conflict-resolution strategies, and track connectivity state.
💡 Pro Tip: Don't start offline-first by "getting the UI running first, then wiring up data later" — design your local schema and sync queue first, then wire the UI to it. Go the other way and you'll end up forcing migration and conflict resolution into the UI after the fact.
Table of Contents
- What Does Offline-First Mean, and How Does It Differ From "Cache"?
- Drift Setup and Type-Safe Queries
- Local Schema and Migration Strategy
- Write Queue with the Outbox Pattern
- Conflict Resolution: Last-Write-Wins vs. Field-Level Merge
- Connectivity State and Background Sync
- Testability: Separating Sync Logic From the UI
- Local Data and Privacy Compliance
- End-to-End Flow: How an Offline Record Gets Synced
- FAQ
- How do you write an offline-first app in Flutter?
- What's the difference between drift and sqflite?
- How are conflicting records resolved during sync?
- How do you migrate a local database schema in Flutter?
- Update (September 2026)
- Conclusion
- Sources
What Does Offline-First Mean, and How Does It Differ From "Cache"?
It's easy to think of offline support as "store the API response, show it again" — that's a cache strategy, and it's one-directional: server to device. Offline-first is bidirectional: the user can create new data even without a connection (filling a form, adding a note, changing a status), and that data is written to the local database and synced to the server once the connection returns.
This distinction directly shapes the architecture:
- Cache architecture: server data = the single source of truth; the local store is just a copy.
- Offline-first architecture: the local database = the primary source of truth; the server is the sync target. The UI always reads from local storage and never waits directly on the result of a network request.
That's why the first decision in an offline-first Flutter app isn't "which HTTP client" — it's "which local database, and which schema."
Drift Setup and Type-Safe Queries
drift is a Dart ORM built on top of SQLite that works via code generation. The packages you need to install:
1dependencies:2 drift: ^2.26.03 drift_flutter:4 path_provider:5 6dev_dependencies:7 drift_dev:8 build_runner:Note: The examples in this article are written against the drift 2.26.0 API.
Table definitions are written as Dart classes instead of raw SQL — this gives you compile-time type safety:
1class TodoItems extends Table {2 IntColumn get id => integer().autoIncrement()();3 TextColumn get title => text()();4 BoolColumn get isSynced => boolean().withDefault(const Constant(false))();5 DateTimeColumn get updatedAt => dateTime().nullable()();6}When you run dart run build_runner build, drift generates a database.g.dart file from this table definition. Your database class extends this generated class:
1@DriftDatabase(tables: [TodoItems])2class AppDatabase extends _$AppDatabase {3 AppDatabase() : super(_openConnection());4 5 @override6 int get schemaVersion => 1;7}Queries are written as Dart method chains rather than raw SQL strings — so if you misspell a column name you get a compile error, not a query that silently returns nothing at runtime:
1// Insert2await database.into(database.todoItems).insert(3 TodoItemsCompanion.insert(title: 'Write report', updatedAt: Value(DateTime.now())),4);5 6// Read7final items = await database.select(database.todoItems).get();Concept | Raw SQL approach | drift approach |
|---|---|---|
Column name typo | Silent/runtime error | Compile-time error |
Schema change | Manual string update | Type-safe migration API |
Query result | Map<String, dynamic> | Generated Dart class |
Testing | Real file or mock | In-memory NativeDatabase instance |
Local Schema and Migration Strategy
Your app's schema will change after it ships — a new column, a new table, a removed field. drift manages this through the schemaVersion and migration getters:
1@override2int get schemaVersion => 3;3 4@override5MigrationStrategy get migration => MigrationStrategy(6 onCreate: (Migrator m) async {7 await m.createAll();8 },9 onUpgrade: (Migrator m, int from, int to) async {10 if (from < 2) {11 await m.addColumn(todoItems, todoItems.isSynced);12 }13 if (from < 3) {14 await m.addColumn(todoItems, todoItems.updatedAt);15 }16 },17);Critical point: the checks inside onUpgrade must be cumulative. If a user upgrades directly from version 1 to 3, both the from < 2 and from < 3 blocks must run in sequence — otherwise users who skip the from < 2 block never get the isSynced column, and any query touching it crashes with "no such column." Migration tests that simulate a real device's version history are the step most often skipped, because developers usually test a clean install, not an upgrade.
Write Queue with the Outbox Pattern
When a user creates a record while offline, something needs to track when and how that write reaches the server. The common pattern for this is the outbox pattern — it comes from the general distributed-systems literature and isn't specific to drift or Flutter:
- The write doesn't go directly to the server; it's first saved to a local
pending_operations(outbox) table, in the same transaction as the actual data. - Once the connection returns, a background sync worker reads the outbox table in order, sends each record to the server, and deletes it from the outbox on success.
- The
isSyncedflag on the actual data table becomestrueonce the corresponding outbox record has been successfully cleared.
1class PendingOperations extends Table {2 IntColumn get id => integer().autoIncrement()();3 TextColumn get entityId => text()();4 TextColumn get operationType => text()(); // create / update / delete5 TextColumn get payloadJson => text()();6 DateTimeColumn get createdAt => dateTime()();7}The outbox's most important guarantee: the actual data and the outbox record are written in the same transaction. Write them separately, and a crash between the two leaves you with either data that has no sync record (it never reaches the server), or the reverse.
Conflict Resolution: Last-Write-Wins vs. Field-Level Merge
When two devices edit the same record offline and then sync, which change wins? There's no single correct answer — it depends on your data model, and there are two common strategies:
- Last-write-wins (LWW): every record carries an
updatedAttimestamp, and when two writes conflict the server declares the one with the newest timestamp the winner. Simple to implement, but the other device's change is silently lost. - Field-level merge: each field of the record is tracked with its own timestamp; at conflict time, a decision is only made for the fields that actually conflict, and the rest are preserved from both sides. More complex to implement, but a lower risk of data loss.
Practical rule of thumb: if a user fills out a single form and conflicts are rare, LWW is enough. If multiple users/devices work on the same record concurrently (a shared list, a shared note), it's worth investing in field-level merge — otherwise users notice their work "disappearing" and trust erodes.
Connectivity State and Background Sync
For the sync worker to know when to run, you need to track connectivity state. In the Flutter ecosystem, connectivity_plus is used for this:
1final result = await Connectivity().checkConnectivity();2// result: List<ConnectivityResult> — multiple interfaces can be active at once3 4Connectivity().onConnectivityChanged.listen((result) {5 if (!result.contains(ConnectivityResult.none)) {6 syncWorker.trigger();7 }8});As of when this article was written (2025-03-25), the current connectivity_plus version is 6.1.3 (released February 7, 2025).
Two important behavior notes, straight from the package's own documentation:
checkConnectivity()returns a list, not a single result — a device can have both Wi-Fi and mobile data active at once.onConnectivityChangedshould only emit distinct values; but an interface being "present" is not a guarantee of actual internet access (captive portals, restricted networks). Verify with a real ping/health-check before a critical sync.
Signal | What it means | Effect on sync |
|---|---|---|
ConnectivityResult.wifi/.mobile | A network interface is active | A sync attempt can be triggered |
ConnectivityResult.none | No interface at all | The outbox keeps accumulating |
Interface present but request times out | Captive portal / restricted network | Sync fails, retries |
Testability: Separating Sync Logic From the UI
Embed the outbox worker and conflict resolver inside the widget tree, and you're forced to set up widget-test infrastructure just to test them — slow and brittle. Instead, move the sync logic into a plain Dart class (repository/service); the widget only calls it. This lets you:
- Unit test the sync worker with an in-memory database instance, without needing a real filesystem or widget-test framework.
- Verify error scenarios like "server returned 500, the outbox record must not be deleted" with a fake HTTP client, with no UI involved.
- Run migration tests repeatably against different
schemaVersioncombinations.
Practical separation: a SyncService class takes an AppDatabase and an ApiClient, both via constructor injection — tests pass a fake ApiClient, while AppDatabase uses real drift infrastructure with an in-memory connection instead of a file. This way the test also covers real SQL behavior (constraints, type conversions) — a fake "in-memory Map" standing in for the database doesn't give you that guarantee.
Local Data and Privacy Compliance
In an offline-first architecture, data no longer lives only on the server — it also persists permanently on the user's device, which means additional responsibility under privacy regulations like GDPR:
- Personal data minimization: only write the fields the app actually needs; don't copy every field from the server "just in case."
- Deletion requests (right to erasure): when a user deletes their account, the local copy needs cleaning up along with the server-side deletion — typically a
deleteAll()/database-file-deletion step on logout or in the account-deletion flow. - Device-loss scenario: an unencrypted local database can leak data if the device is lost or stolen. For sensitive tables (identity, payment, health), consider field-level encryption on top of OS disk encryption; drift's Encryption page covers an encrypted
NativeDatabaseand encrypting an existing database (see Sources).
End-to-End Flow: How an Offline Record Gets Synced
A typical scene: the user is in an elevator or on the subway, no connection, filling out a form. The UI shows no warning and accepts the record, since the write goes straight to the local database without waiting on the network. The record lands in the outbox with isSynced: false, in the same transaction.
Once back above ground, the connectivity_plus listener fires and the sync worker drains the outbox in order. If the same record was also changed on another device meanwhile, the conflict resolver (LWW or field-level merge, whichever was chosen) kicks in. The only thing visible to the user: the app never stopped.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
I've prepared a checklist of things not to skip when you apply the drift + outbox + conflict-resolution architecture from this article to your own project. Each item corresponds to one of the sections above, and these are the critical points to check off one by one before going to production.
FAQ
How do you write an offline-first app in Flutter?
Define your local schema with drift (table classes + migration strategy), then set up the UI to read directly from that database — a network request never blocks it. Writes go local first, landing in the outbox table within the same transaction; once the connection returns, a sync service drains the outbox and reconciles with the server.
What's the difference between drift and sqflite?
sqflite is a low-level package giving raw access to SQLite — you write queries by hand as SQL strings. drift adds code generation on top: tables are Dart classes, queries are type-checked at compile time, and database.g.dart is generated automatically. sqflite can be enough for a small, single-table cache; for an app whose schema will grow and carry a migration history, drift's type safety lowers the error rate.
How are conflicting records resolved during sync?
Two common strategies: last-write-wins (the record with the newest updatedAt wins, the other is silently lost) and field-level merge (each field tracked with its own timestamp, a decision made only for fields that actually conflict). LWW is enough for a single-user scenario; shared/multi-user records benefit from field-level merge.
How do you migrate a local database schema in Flutter?
Bump the schemaVersion getter on every schema change, and define onCreate (clean install) and onUpgrade (upgrade) in the migration getter. The if (from < N) checks inside onUpgrade must be cumulative, since users skip versions rather than updating one by one.
Update (September 2026)
This article was written against the drift/connectivity_plus versions current as of 2025-03-25. Since then, three changes may affect the examples in the body:
- Native full-text search: with drift 2.35.0 (around September 2026), FTS5-based functions —
match,matchExp,highlight,snippet,bm25,rank— were added to the Dart API, so offline-first apps needing local search can now use them directly through drift, without a separate FTS layer. - Transaction and migration behavior tightened: newer drift versions start transactions with
BEGIN IMMEDIATE(affecting locking on concurrent writes), and step-by-step migration now throws on a downgrade attempt instead of silently passing. Review both changes when moving the example above to a current drift version. - Realm/MongoDB Atlas Device Sync officially shut down on September 30, 2025: this pushed the "which local DB + which sync engine" question in offline-first Flutter further toward drift plus a separate sync layer — a hand-rolled outbox like this article's, or a third-party sync engine.
Related posts published later:
- Flutter Clean Architecture: A Layered Architecture Guide
- Flutter State Management with Riverpod
- The Complete Flutter Testing Guide
- Flutter Firebase Integration: The Complete Guide
- Flutter Performance Optimization: The Guaranteed 60fps Guide
- Flutter 4 Impeller: The New Render Engine
Conclusion
Offline-first isn't the only way to get rid of the "wait if there's no internet" screen, but in Flutter it's the most mature one: a type-safe local schema with drift, writes queued through the outbox pattern, connectivity tracked to automate sync, and conflicts resolved with a strategy that fits your data model. None of these pieces is complex alone — the difficulty is designing them all from the start, not bolting them onto a UI that's already written.
Firestore's automatic cloud cache is a different contract than the hand-designed local architecture here, and the two aren't interchangeable.
Sources
- drift — Getting started — table definitions, code generation, and database class setup
- drift — Migrations — the
MigrationStrategy,onCreate/onUpgradeAPI - drift — pub.dev version API — package version history (pub.dev JSON API)
- drift — changelog — version-by-version change history
- drift — Encryption — setting up an encrypted
NativeDatabaseand encrypting an existing database - connectivity_plus — pub.dev version API — package version history (pub.dev JSON API)
- connectivity_plus — changelog — version-by-version change history
- connectivity_plus — API documentation —
checkConnectivity(),onConnectivityChangedbehavior - MongoDB Atlas Device Sync — Deprecation — Device Sync deprecation announcement
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

