Offline-First Architecture: Choosing a Local Database and Designing for Sync
Move beyond caching. Master the local-write-then-sync pattern, conflict resolution, and idempotency to pass senior-level system design rounds.
01. Why "Offline-First" Differs from "Caching"
If there’s one conceptual trap developers fall into during a system design interview, it’s treating offline support merely as a "cache." A naive app treats the local device as a thin mirror of the server. It queries the server, displays the data, and maybe caches the JSON for faster load times. But what happens when the user takes an action—say, saving a scanned receipt in an expense tracker—while their train goes through a tunnel?
In a cache-first app, the UI shows a loading spinner until the API call fails, then slaps the user with a generic "Network Error" dialog. The data is lost. The UX is broken.
An offline-first architecture inverts this relationship. The local database is the primary source of truth for the UI. When the user creates that receipt, it is written immediately to the local database, and the UI updates instantly. Server synchronization happens independently in the background. Syncing is no longer a prerequisite for the UI to function; it is a separate layer bolted on top of a rock-solid local foundation.
(UI Updates Instantly)
(Sync Job)
In offline-first, the path from User -> Local DB never fails. The background sync simply waits for connectivity.
02. Local Database Decision Framework
Treating your local database as the primary source of truth means your choice of storage layer carries immense architectural weight. A senior candidate doesn't just list features; they articulate trade-offs and project fit.
Let's dive deeper into the honest reality of these tools:
| Database | Type | Reactive UI Support | The Honest Caveat |
|---|---|---|---|
| Drift | Relational (SQLite) | Excellent (Watch queries as Streams) | Requires code generation; heavy setup. But SQL mistakes are caught by the Dart analyzer at compile time. |
| Isar | NoSQL | Excellent | Its future maintenance status is uncertain enough that recommending it blindly for a new 2026 production app is a red flag. Know this caveat. |
| Hive | Key-Value | Good (ValueListenable) | Do not force it into relational workflows. Great for settings, terrible for a complex expense tracker with categories, tags, and foreign keys. |
| ObjectBox | NoSQL | Good | The core is fast, but you must evaluate their specific commercial/open-source licensing model before committing a client to it. |
| sqflite | Relational | None natively (Manual) | Highest boilerplate, writing raw SQL strings. Pick this only when you need total control and zero extra dependency layers. |
If the data is relational and the team is comfortable with SQL, pick Drift. If it's just user settings or simple caching, use Hive. If you need low-level control, use sqflite. If you want fast NoSQL, you can propose Isar or ObjectBox, provided you immediately flag the maintenance or licensing caveats as risks you'd discuss with the team.
03. The Local-Write-Then-Sync Pattern
Once you've chosen your database, you must define how data moves. The core engine of offline support is the Local-Write-Then-Sync pattern.
Let's return to our expense tracker. When the user taps "Save Receipt", a naive app attempts an HTTP POST. If it fails, the UI throws an error. Under the local-write-then-sync pattern, the flow is completely different:
- The user taps "Save".
- The app writes the Receipt to the local database (e.g., Drift), marking its internal status as
sync_pending. - Because the UI is listening to a Drift Stream of all receipts, the UI updates instantly. The user sees their receipt appear in the list immediately, perhaps with a tiny gray cloud icon indicating it hasn't synced yet.
- Separately, a background sync service queries the database for all records where status is
sync_pending. - When connectivity allows, the service pushes the data to the server. On success, it updates the local record status to
synced, which automatically updates the UI Stream to remove the gray cloud icon.
04. Conflict Resolution: The Honest Baseline
If data lives in two places (device and server), you will eventually face conflicts. What happens if a user edits a receipt category offline on their phone, but also edits the same receipt from their laptop via the web dashboard?
The standard, honest baseline for most Flutter apps is Last-Write-Wins (LWW). Every record has an updated_at timestamp. When the mobile app reconnects and pushes its edit, the server compares the timestamp of the incoming payload against the database. Whichever is newer overwrites the other.
LWW is easy to reason about and implement, but a senior developer must state its real limitation during an interview: a legitimate concurrent edit can be silently lost. If the phone's edit happened five minutes after the laptop's edit, the phone "wins" and the laptop's changes are wiped out.
Interviews often drift toward CRDTs (Conflict-free Replicated Data Types) to solve this. CRDTs are mathematical structures that allow concurrent edits to merge seamlessly (like Google Docs).
How to handle this: "I’m aware CRDTs exist for resolving deep concurrent edits without data loss. However, for a standard CRUD app like this receipt tracker, Last-Write-Wins with granular field-level timestamps is usually the pragmatic business choice. I haven't necessarily needed to implement full CRDTs in production."
Honesty beats bluffing deep distributed-systems theory you haven't actually built.
05. Background Sync Triggers
How does the app know when to execute that sync queue? You need a multi-layered approach, typically combining two mechanisms at a conceptual level:
- Connectivity-Change Listeners: While the app is open and running, you listen to a stream (like
connectivity_plus). The moment the device switches from offline to Wi-Fi, you trigger a sync attempt. This handles the "train emerging from a tunnel" scenario instantly. - Periodic Background Jobs: What if the app was fully closed while the user was offline? You need OS-level scheduling. Using plugins that wrap
WorkManager(Android) andBGTaskScheduler(iOS), you schedule periodic jobs. The OS decides exactly when to wake up your app in the background to execute the sync logic.
06. End-to-End: Idempotency & Backoff
Networks don't just fail completely; they lie. Imagine the mobile app sends the receipt payload to the server. The server successfully saves it to AWS, but right as the server sends back the `200 OK` response, the mobile connection drops.
The client thinks the request failed. The server thinks it succeeded.
When the client gets connectivity back, its sync engine will grab that `sync_pending` record and retry the upload. If the server isn't smart, it will create a duplicate receipt. The solution to this distributed-systems nightmare is an Idempotency Key.
Key: d9b2...Key: d9b2...When the receipt is created locally, the client generates a unique UUID. It passes this UUID as a header with every sync attempt. The server checks: "Have I seen this UUID before?" If yes, it just returns the previous success response without creating a new database row. The retry is now completely safe.
Future<void> syncReceipt(Receipt receipt) async {
final uri = Uri.parse('https://api.example.com/receipts');
final request = http.MultipartRequest('POST', uri);
// Critical: The server uses this to deduplicate retries
request.headers['Idempotency-Key'] = receipt.uuid;
// ... attach file and fields
final response = await request.send();
if (response.statusCode == 200 || response.statusCode == 201) {
await localDb.markAsSynced(receipt.uuid);
} else {
throw SyncException('Will retry later via exponential backoff');
}
}
Finally, when retrying failed requests, you must use Exponential Backoff (e.g., retry in 2s, then 4s, 8s, 16s). If a server outage drops thousands of active users, and they all aggressively retry the instant they regain connection, they will inadvertently DDoS your own backend. Backoff spreads out the thundering herd.
07. Mini System-Design Checklist
If you are handed an offline-sync problem on a whiteboard, structure your answer around these core pillars:
- Source of Truth: Local DB (Drift for relational). UI observes DB via Streams.
- Write Path: Write local first -> Mark
sync_pending-> Enqueue. - Sync Triggers: Active listener (connectivity) + Passive scheduler (WorkManager).
- Conflict Strategy: Last-Write-Wins (LWW) via
updated_attimestamps. - Network Resilience: Idempotency keys on the client + Exponential backoff on retries.
Q: What happens if the exact same receipt record is edited both offline on the phone, and online on the web portal, before the phone reconnects?
Pointer: LWW timestamp evaluation. The last edit wins. Be transparent that the older edit is permanently overwritten.
Q: Why not just use SharedPreferences/Hive for everything?
Pointer: Filtering a queue of hundreds of pending records by status, category, or retry-count is slow and fragile in Key-Value stores. Relational tools handle complex queue states better.
Senior-Level Sample Answer
"To support offline document uploads reliably, I wouldn't just cache the UI. I’d implement a local-write-then-sync architecture, likely using Drift since it provides reactive streams. When a user saves a receipt offline, we insert it into Drift as `pending`. The UI, listening to a Drift stream, updates instantly so the user isn't blocked. Behind the scenes, a sync worker—triggered by either a connectivity listener or a background OS task—processes the queue. Crucially, every upload includes a client-generated UUID as an idempotency key. This ensures that if our mobile client drops the network right as a successful response is coming back, our inevitable retry won't result in duplicate records on the server."
Without looking at the text above, can you explain exactly how an idempotency key prevents duplicate records during a patchy network reconnection?
Official Resources
Written by Flutter Solution · Covering the Flutter & Dart ecosystem from Mumbai, India · fluttersolution.com

Comments
Post a Comment