<FlutterSolution/> flutter · dart · clean architecture

Flutter Interview Prep # 22 Offline-First Architecture: Choosing a Local Database and Designing for Sync

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.

User Action
Local DB
(UI Updates Instantly)
Queued Data
Remote Server
(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.

Drift
Compile-time checked SQL queries
Native reactive streams
⚠️ Steep learning curve if team avoids SQL
Isar
Blazing fast NoSQL
Elegant typed queries
⚠️ Uncertain maintenance status (2025/2026)
Hive (or Hive CE)
Dead-simple Key-Value storage
Minimal boilerplate
⚠️ Wrong tool for complex relational data
ObjectBox
High-performance NoSQL
Built-in sync solutions (enterprise)
⚠️ Commercial licensing requirements

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.
💡 The Interview Decision Matrix

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:

  1. The user taps "Save".
  2. The app writes the Receipt to the local database (e.g., Drift), marking its internal status as sync_pending.
  3. 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.
  4. Separately, a background sync service queries the database for all records where status is sync_pending.
  5. 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.

😌 The Honest Take: CRDTs

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) and BGTaskScheduler (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.

Idempotency Retry Flow
Attempt 1
Key: d9b2...
201 Created
Attempt 2 (Retry)
Key: d9b2...
200 OK (Deduplicated)

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.

Dart
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_at timestamps.
  • Network Resilience: Idempotency keys on the client + Exponential backoff on retries.

🧠 Interviewer's Follow-Up

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."

✅ Quick Self-Check

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

Nachiketa Pandey
// written by

Nachiketa Pandey

Senior Flutter Developer · Cross-Platform & Fintech Applications

Building production Flutter apps at Kotak Neo — clean architecture, BLoC, agentic AI integrations, and fintech-grade security. Sharing everything I learn, one post at a time.

Flutter & Dart Clean Architecture BLoC Firebase FinTech
View Full Portfolio

Comments

Nachiketa
Meet Author// tap to open