Offline-First Flutter Architecture: How I Built a POS That Keeps Selling Offline
Muhammad SherazSenior Full-Stack Mobile Engineer9 min read- flutter
- offline-first
- architecture
- pos
A point-of-sale terminal that stops selling when the WiFi drops is not a product — it is a liability. On the POS and inventory platform, the hard requirement was simple: every sale must be captured, connectivity or not. This is the architecture that makes that guarantee, and it applies to any Flutter app that has to work offline.
- // storage layers in my typical offline stack
- 3
- // outbox row per mutation, in the same transaction
- 1
- // client-generated keys make every sync idempotent
- UUID
Principle: the local database is the source of truth
Offline-first means the network is an optimization, not a dependency: every read and every write goes to the local database first, and sync happens in the background.
Most 'offline support' is actually a cache in front of an online app — it reads stale data but cannot safely write. Real offline-first flips the model: the UI talks only to local storage, and a sync engine moves data to the server when it can. This matches Google's own offline-first architecture guidance, which defines an offline-first app as one that performs its core functionality without internet access. The app is fully functional in airplane mode by construction, not by exception handling.
Choosing the local store
| Store | Best for | Where I've used it |
|---|---|---|
| SQLite (sqflite/Drift) | Relational data: sales, line items, orders | Field-sales orders, quiz and score history |
| Hive | Fast key-value data: settings, session, caches | Learning-platform content caches |
| Firestore offline persistence | Real-time synced documents with built-in caching | Gage and StarShare |
In MediTech, SQLite holds every order offline and a manual Firestore backup/restore covers device loss. Where live multi-device sync matters, Firestore's built-in offline persistence is the better fit. Pick per data shape, not one-size-fits-all.
The sync engine: an outbox, not a prayer
- Write to an outbox table. Every mutation is committed locally in the same transaction as an outbox row: operation type, payload, timestamp, and a client-generated UUID.
- Idempotency via client IDs. The server upserts on the client UUID, so a retry after a mid-flight timeout can never double-post a sale.
- Order matters. Replay the outbox strictly in sequence per entity — a stock adjustment before its sale corrupts inventory counts.
- Conflict policy per entity. Sales are append-only (no conflicts possible); stock levels use server-side reconciliation; profile edits use last-write-wins with an audit trail.
- Backoff and batching. Sync on connectivity-restored events with exponential backoff, and batch outbox rows to keep re-sync cheap after a long offline stretch.
// Simplified sketch (Drift): the sale and its outbox row// commit in ONE transaction — neither can exist without the other.Future<void> recordSale(Sale sale) async { await db.transaction(() async { await db.into(db.sales).insert(sale.toCompanion()); await db.into(db.outbox).insert(OutboxCompanion.insert( clientId: sale.id, // UUID minted on the device entity: 'sale', op: 'create', payload: jsonEncode(sale.toJson()), )); }); sync.schedule(); // best effort — the outbox survives a crash}The UI never awaits the network: recordSale returns as soon as the local transaction commits, and the sync engine drains the outbox in order whenever it can reach the server. Because the server upserts on clientId, replaying a row twice is harmless.
Testing offline-first properly
The failure modes live in the transitions, not the states. My test matrix always covers: going offline mid-request, the process being killed with a non-empty outbox, clock skew between device and server, and two devices selling the same stock while both offline. Airplane-mode-on-the-happy-path is not a test plan.
When offline-first is worth it
It costs real engineering — an outbox, idempotent APIs and a conflict policy — which is why it is a scoping decision I make explicitly at the start. But for POS, field-work, healthcare and any market with unreliable connectivity, it is the difference between an app people trust and an app people abandon.
The platform-level write-up is in the POS and inventory platform case study. Questions about any of it? Get in touch.
~/sherazi.dev$./say-hello
Say hello.
Questions about a case study, one of the packages, AI tooling or Flutter in general are always welcome.
// or browse the packages on pub.dev(opens in a new tab)