Users do not care that your API is down or that the warehouse has no signal. They care that the app feels broken. In field operations, logistics, retail, and anywhere with intermittent connectivity, offline capability is not a nice-to-have. It decides whether the app gets used or abandoned.
Many apps add an "offline mode" as an afterthought: cache a few screens and show an error when the user tries to save. That approach breaks exactly when it matters. Offline-first inverts the design. The local database on the device is the primary source the UI reads from, every write goes to local storage first, and synchronization with the server is a background process the UI never blocks on. The interface responds instantly because it never waits on a network request that may time out, and the app catches up when connectivity returns.
This changes how you build features. Each screen observes a local query. Each action writes locally and records an intent to sync. The network becomes an optimization rather than a requirement. In our Flutter projects we build this as four layers.
Layer 1: a local database you can trust
The local store holds the entities the user works with, plus sync metadata: a stable client-generated ID (such as a UUID), a last-modified timestamp or version, and a sync status (synced, pending, failed, or conflict).
For Flutter, we recommend actively maintained options:
- drift (on SQLite) for relational data, typed queries, reactive streams, and explicit schema migrations. It is our default when the data has relationships and reporting needs.
- ObjectBox when you want a fast object store with a simple API and good performance on large local datasets.
- Hive CE, the community-maintained continuation of Hive, for simpler key-value or document-style storage.
We avoid starting new projects on the original Hive or Isar packages: both have seen little maintenance since 2023, and community forks exist precisely because of that. Whatever you choose, version your schema from day one and test migrations on real devices. A failed migration on a phone in the field is much harder to fix than one on a server.
Layer 2: a sync queue with clear rules
Every local change produces an entry in an outbox: the operation, the entity ID, the payload, and the time. A background sync worker drains the queue when connectivity returns, in order, with retries and exponential backoff.
Design guidance we follow:
- Make server operations idempotent. Send the client-generated ID and an operation ID, so a request retried after a timeout does not create a duplicate record.
- Push and pull as separate steps. Push pending local changes, then pull server changes since the last successful sync using a cursor or timestamp.
- Sync in small batches so a single bad record does not block everything behind it, and surface failed items instead of retrying them forever.
- Respect the platform. Background execution on iOS and Android is limited and scheduled at the operating system's discretion. Sync when the app is opened or resumed, and use packages such as workmanager for periodic background work where the platform allows it.
Layer 3: conflict rules that match the domain
Conflicts happen when the same record changes on the device and on the server before they sync. There is no single correct strategy; it depends on the data:
- Last write wins for low-risk fields such as user preferences.
- Field-level merge for records where different people usually edit different fields, such as contact details and notes.
- Append-only records for events such as visits, readings, or stock counts, where each entry is a new record and conflicts largely disappear.
- Manual review for financial transactions, approvals, or anything with legal weight. Flag the conflict, keep both versions, and let a person decide.
Decide these rules with the business owners, not just the developers. They are policy decisions expressed in code.
Layer 4: optimistic UI and honest indicators
Optimistic UI updates the screen immediately after a local write, without waiting for the server. That is what makes offline-first apps feel fast. But it must be honest: show a small, consistent indicator when items are pending upload, show when the last successful sync happened, and make failures visible with a clear way to retry.
Users trust an app that tells them the truth about their data. They abandon one that silently loses it.
Security and testing
Devices get lost or stolen. Encrypt sensitive local data (for example, SQLite with SQLCipher, or the encryption options your chosen database provides), keep keys in the platform keystore through flutter_secure_storage, and limit how much history is stored offline. Support remote sign-out that clears local data on the next launch.
Test offline behavior deliberately: airplane mode, flaky networks, the app being killed during sync, clock differences between device and server, and app upgrades with pending items still in the queue.
One codebase, consistent behavior
Flutter's single codebase means the offline-first architecture is implemented once and behaves the same on Android and iOS. The four layers above are not extra work added at the end; they are the shape of the app from the first screen. For teams working in the field, in warehouses, or across regions with unreliable networks, that is what turns a mobile app into a tool people depend on.

