Offline-First Architecture in Mobile System Design
Offline-first means: your app should work smoothly even when the internet is bad or gone. The local device (not the server) becomes the source of truth. The backend just syncs in the background.
Mobile networks are unreliable — elevators, metro, low signal areas. A good app doesn't freeze or show blank screens just because wifi dropped.
1. What Offline-First Actually Means
Normal (online-first) app: tap → wait for server → then update UI. If network fails, user sees a spinner or error.
Offline-first app: tap → save to local DB instantly → UI updates immediately → sync happens later in background.
Offline-first doesn't mean "no internet ever." It means the app doesn't depend on internet to feel usable.
2. Why It Matters
Mobile apps face things servers never do: flaky network, battery limits, background restrictions, app getting killed mid-task, storage limits, conflicting edits across devices.
Benefits: faster UX, less server dependency, better retention in low-network regions (very relevant for India), graceful failure handling.
Apps that need this: messaging, notes, tasks, maps, banking, e-commerce.
3. Core Data Flow
UI observes → Local Database → Sync Engine → Remote APIUI never talks to network directly. It only reads from local DB. Server data flows into local DB, and local DB pushes to UI automatically.
Two directions:
Read: Remote → Local DB → UI
Write: User action → Local DB → Sync Queue → Remote
4. Main Building Blocks
Local database (Room on Android)
Repository (decides local vs remote, UI doesn't care)
Sync engine (uploads/downloads changes)
Pending operations queue
Conflict resolution strategy
Connectivity monitor
Background worker (WorkManager)
Retry/backoff system
5. Local Database — the Foundation
Stores cached data + user-created data + sync metadata.
data class TaskEntity(
val id: String,
val title: String,
val completed: Boolean,
val updatedAt: Long,
val syncStatus: SyncStatus, // SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE, FAILED, CONFLICT
val version: Int
)
syncStatus tells the sync engine what still needs to go to the server.
6. Repository — the Traffic Cop
UI should never decide "cache vs network" — that logic lives in the repository.
fun observeTasks(): Flow<List<Task>> = taskDao.observeTasks()
suspend fun refreshTasks() {
val remoteTasks = api.getTasks()
taskDao.upsertAll(remoteTasks.map { it.toEntity() })
}
UI just observes local data — it never waits on the API directly.
7. Write Locally First
Golden rule: save locally before syncing.
suspend fun completeTask(taskId: String) {
taskDao.updateCompleted(
id = taskId,
completed = true,
syncStatus = SyncStatus.PENDING_UPDATE,
updatedAt = System.currentTimeMillis()
)
syncScheduler.schedule()
}
Even offline, user sees the task marked complete instantly.
8. Sync Engine
Two jobs: upload pending local changes, download remote changes, then merge.
Read pending ops → Upload to server → Update local sync state
→ Fetch remote changes → Merge into local DB
Must be idempotent — running sync twice shouldn't duplicate or corrupt data.
9. Pending Operations Queue
Instead of just flagging rows as "pending," some apps log actual operations (CREATE_TASK, UPDATE_TASK, DELETE_TASK...) so they can be replayed in order. Important for apps like messaging/CRM where order matters.
10. Conflict Resolution
Happens when the same record is edited in two places before sync completes. Common strategies:
Strategy | Use when |
|---|---|
Last Write Wins | Simple stuff — toggles, read status |
Server Wins | Sensitive data — pricing, permissions |
Client Wins | Drafts, local-only prefs |
Manual Merge | Notes, documents — let user choose |
Field-Level Merge | Only merge the fields that actually conflicted |
CRDTs / OT | Real-time collab docs — complex, use only if truly needed |
11. Sync Metadata
Each record usually needs: id, serverId, localId, updatedAt, version, syncStatus, lastSyncedAt.
Local IDs are usually UUIDs, generated on-device so records can be created fully offline, then mapped to a server ID later.
12. Handling Deletes (Tombstones)
Don't delete instantly — mark it:
isDeleted = true
syncStatus = PENDING_DELETE
Hide from UI, but keep the row so sync can still tell the server. Remove permanently only after server confirms.
13. Connectivity ≠ API Availability
Device can show "connected to wifi" but still have no real internet. Don't fully trust connectivity APIs — always handle actual request failures too. Connectivity signal is only useful for triggering sync attempts.
14. Background Sync
On Android → WorkManager. Handles network constraints, retries, periodic work, survives app restarts.
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
WorkManager.getInstance(context).enqueue(syncRequest)
15. Retry with Backoff
Never retry every second — wastes battery + server load. Use exponential backoff: 5s → 30s → 2min → 10min...
Retryable: timeout, no internet, 5xx, rate limit. Non-retryable: 401, validation error, record deleted — don't blindly retry these.
16. UI Should Show Sync State
Don't scare users with generic errors when their data is actually safe locally.
❌ "Failed to save" ✅ "Saved on this device. Will sync when connection returns."
Show: offline indicator, syncing spinner, pending changes badge, last synced time.
17. Read Strategies
Cache-only — static/reference data
Network-then-cache — needs freshness, falls back to cache
Cache-then-network (most common) — show cache instantly, refresh in background
Stale-while-revalidate — show old data, refresh quietly (feeds, news)
18. Write Strategies
Online-only — payments, login, security actions
Offline-queued — notes, tasks, forms
Optimistic — likes, mark-as-read, task completion
Confirmed — bank transfer, checkout (wait for server before updating UI)
Not everything should be offline — pick per feature.
19. Freshness Rules
Some data can be stale (profile name, saved notes). Some can't (account balance, delivery ETA, permissions). Define a simple rule, e.g.:
Use cache if less than 10 min old, else refresh.
20. API Design That Supports Offline Clients
Helpful backend features: idempotency keys, updatedSince queries, version numbers, soft deletes, bulk sync endpoints.
GET /tasks?updatedSince=2026-07-12T10:00:00Z
POST /sync/tasks
{
"operations": [{
"idempotencyKey": "op_123",
"type": "UPDATE_TASK",
"entityId": "task_456",
"payload": { "completed": true },
"baseVersion": 3
}]
}
Idempotency keys matter because mobile clients retry the same request often — without them you get duplicate records.
21. Security
Encrypt sensitive local data, use secure storage for tokens, clear data on logout, handle token expiry mid-sync. For banking/health apps — only cache what's truly safe to store offline.
22. Storage Management
Devices have limited space. Use: cache eviction, data expiry, pagination, download-on-demand. E.g. music app lets you download specific playlists, maps app lets you download specific regions — not everything.
23. Quick Example — Task App
Create: generate UUID → save as PENDING_CREATE → show instantly →
WorkManager syncs → upload → mark SYNCED
Update: edit locally → PENDING_UPDATE → sync with version →
resolve conflict if server rejects
Delete: mark isDeleted + PENDING_DELETE → hide from UI →
sync delete → remove row after server confirms
24. Common Mistakes
UI directly depends on network response
No local DB at all
No retry/backoff strategy
No conflict handling
Deleting rows before sync confirms
Trusting "connected" = "API reachable"
No idempotency keys → duplicate data
Not showing sync status to the user
25. What to Test
Create/update/delete while offline
Sync succeeds when network returns
Sync fails mid-way, app killed before it finishes
Retry doesn't duplicate data
Conflict gets detected correctly
Token expires mid-sync
26. When You Actually Don't Need This
Skip full offline-first for: simple static-content apps, apps needing real-time server validation for every action, internal tools always used on reliable wifi. Even then — basic caching + graceful failure handling is still worth it.
Conclusion
Offline-first = local DB as source of truth, backend as sync partner. Core pieces: local DB, repository, sync engine, operation queue, retry/backoff, conflict resolution, and clear sync UI states. Good offline-first apps never punish users for bad network — they save work instantly and recover quietly when connection comes back.
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.