Skip to content
Yeti Technology

Offline-first Apps11 February 2026 · 9 min read

Offline-first Flutter for Enterprise Field Apps

Field apps live where the network doesn't. Here is how we architect Flutter apps that treat the local database as the source of truth and sync as an eventually-consistent background concern.

DW

Daniel Whitmore

Principal Mobile Engineer

Most Flutter tutorials assume a live connection. You fetch from an API, show a spinner, render the result. That model quietly collapses the moment you deploy to a delivery driver in a regional depot, a technician in a plant room, or a picker in the steel-clad back of a warehouse. Connectivity in these contexts isn't binary; it's a spectrum of lie-to-you states where the radio reports four bars, TCP handshakes succeed, and requests still hang for ninety seconds before timing out.

After shipping several of these systems, my strongest opinion is this: offline-first is not a networking feature you bolt on later. It's a data architecture decision that shapes your entire domain layer. The apps that work are the ones where the local database is the source of truth and the server is treated as a peer you eventually reconcile with. Everything else is a variation on that theme.

Local-first means the UI never waits for the network

The defining rule of a local-first app is that every read and every write completes against local storage synchronously, or near enough. The user taps 'complete job', the record is written to SQLite, the UI updates, and the driver moves on. Whether that change has reached the server is irrelevant to the interaction. Sync happens later, on its own schedule, and its failures must never block the person doing the work.

This inverts the usual dependency direction. Your repositories don't call an HTTP client and cache the result; they read and write the database, and a separate sync engine moves deltas in both directions. The UI subscribes to database streams, so a successful sync that pulls down a changed record simply flows through the same reactive pipeline as a local edit. There is one path into the UI, and it always originates from the database.

Choosing your local store: Drift versus Isar versus raw SQLite

The store you pick constrains how far you can push the architecture, so this decision comes early. My default for enterprise field apps is Drift (formerly Moor). It sits on SQLite, which means battle-tested durability, real transactions, foreign keys, and the ability to hand a DBA a file they understand when something goes wrong at 2am. Drift gives you typed queries, reactive streams out of the box, and migrations you can actually reason about and test.

  • Drift: relational, transactional, excellent for data with genuine relationships (jobs, line items, assets, signatures). Reactive queries make the local-first UI trivial. Migrations are explicit and testable. This is my recommendation for most field apps.
  • Isar: fast, ergonomic NoSQL with great query performance and easy indexing. Good when your data is document-shaped and relationships are shallow. Weaker story around complex joins and long-term migration confidence at the time of writing.
  • Raw SQLite via sqflite: reach for it only when you need something the abstractions genuinely block. You'll rebuild reactivity and type-safety yourself, which is rarely worth it.

The deciding factor is usually the shape of your data. Logistics and field-service domains are relational to the core — a work order has tasks, tasks have parts, parts have stock movements. Fighting that with a document store creates its own tax. Go relational unless you have a specific reason not to.

The sync engine: outbox pattern first

Do not scatter API calls through your codebase and hope. Every local mutation that needs to reach the server should also write an outbox entry inside the same database transaction. The outbox is an append-only log of intents — 'this job was completed', 'this note was added', 'this quantity was adjusted' — each with a stable client-generated ID, a payload, and a status.

class OutboxEntries extends Table {
  TextColumn get id => text()(); // client-generated UUID
  TextColumn get entity => text()();
  TextColumn get operation => text()(); // create/update/delete
  TextColumn get payload => text()(); // serialised JSON
  IntColumn get attempts => integer().withDefault(const Constant(0))();
  DateTimeColumn get createdAt => dateTime()();
  @override
  Set<Column> get primaryKey => {id};
}

A single sync worker drains the outbox in order, posting each entry and deleting it on a 2xx. Because entries carry a stable ID, the server can deduplicate — a retry after a dropped response is idempotent rather than a duplicate job. This one property eliminates the most common and most damaging field-app bug: the phantom duplicate created when the request succeeded but the ack never arrived over a marginal link.

Client-generated IDs are non-negotiable here. If the server mints IDs, you cannot create related records offline, because the child has nothing to point at. UUIDs (or ULIDs, if you want sortable-by-time keys) let a technician create a job, add three tasks, and attach a photo, all offline, all correctly linked, before a single byte reaches the backend.

Conflict resolution: be honest about what your domain needs

Two devices edit the same record while both are offline. When they sync, who wins? There is no universally correct answer, only trade-offs you must make deliberately per entity.

Last-write-wins

Simple, cheap, and correct often enough. Attach a server-authoritative timestamp or a version counter, and the later write clobbers the earlier one. The failure mode is silent data loss: someone's edit vanishes and nobody is told. That's acceptable for a 'last known odometer reading' and unacceptable for a safety inspection checklist. Use LWW where the field is a snapshot of current state, not an accumulation of intent.

CRDTs and operation-based merging

Conflict-free replicated data types let concurrent edits merge deterministically without a central arbiter — a counter that both devices increment ends up correctly summed, a set that both add to ends up with the union. They're powerful, and they're also a genuine engineering commitment: more storage, more complexity, and a mental model your whole team must hold. My advice is to resist adopting a full CRDT framework wholesale. Instead, model the specific fields that need merge semantics — quantities, append-only logs, tallies — as small purpose-built mergeable types, and leave everything else on LWW.

Background sync and flaky connectivity

Sync should run on three triggers: on app foreground, on a connectivity-regained event, and on a periodic background task via WorkManager (Android) or BGTaskScheduler (iOS). Treat background sync as opportunistic — the OS will not honour your schedule precisely, especially on iOS, so never let correctness depend on it. It's a top-up, not a guarantee.

For flaky links, the details matter more than the strategy:

  1. Set aggressive client-side timeouts. A field app hanging for the default 60-plus seconds on a dead link is worse than failing in five and retrying later.
  2. Use exponential backoff with jitter on the outbox, and cap attempts before flagging an entry for manual review rather than retrying forever.
  3. Don't trust connectivity_plus alone. 'Connected to Wi-Fi' can mean 'connected to a depot access point with no upstream'. Confirm reachability with a lightweight authenticated request before assuming you can sync.
  4. Chunk large pulls and make them resumable. A driver who regains signal for eight seconds in a valley should make measurable progress, not restart a 50MB download.

Testing offline behaviour

This is where teams cut corners and pay for it in the field. Offline correctness is invisible in a demo and catastrophic in production, so it needs deliberate coverage.

  • Unit-test the sync engine against a fake transport that you can script to fail, delay, return duplicates, and drop acks after a successful write. The dropped-ack case is the one that finds real bugs.
  • Test conflict resolution with property-based tests: generate concurrent edit sequences and assert your merge is commutative and converges regardless of arrival order.
  • Test Drift migrations against real fixture databases captured from previous app versions, not freshly generated schemas. Field devices run old versions for a long time.
  • Run integration tests with the network toggled mid-transaction to prove the outbox and UI stay consistent when a write lands locally but never reaches the server.

The payoff for all this is an app that feels instant because it never waits on a radio, and that a field team actually trusts because it never loses their work. That trust is the whole game. Build the data layer for the worst network you'll ever see, and the good networks take care of themselves.

flutteroffline-firstsyncdriftarchitecture

Frequently asked questions

Should I use client-generated or server-generated IDs?
Client-generated (UUID or ULID). Server-minted IDs make it impossible to create related records offline, because a child record has no valid parent key to reference until it syncs. Client IDs also make retries idempotent, which prevents duplicate records over flaky links.
Drift or Isar for an offline-first enterprise app?
For relational, transactional field-service and logistics data I recommend Drift on SQLite: real transactions, foreign keys, reactive queries, and testable migrations. Choose Isar when your data is document-shaped with shallow relationships and you want maximum query ergonomics and speed.
Do I need CRDTs, or is last-write-wins enough?
Most fields are fine with last-write-wins using a server timestamp or version counter. Reserve merge semantics (small purpose-built CRDT-like types) for the few fields that accumulate intent, such as quantities, tallies, or append-only logs, where silent overwrites would lose real work.

Related services

Have a project in mind?

Tell us what you're building. We'll bring senior engineers and a candid view of what it takes.

Or send a message