Offline-First Architecture

📖 Concept

Offline-first architecture ensures your app works fully without a network connection, syncing data when connectivity returns. This is a critical differentiator at Google interviews.

Core principles:

  1. Local database is the source of truth — UI always reads from local storage
  2. Optimistic updates — User actions apply immediately to local DB, then sync
  3. Background sync — Use WorkManager for reliable sync when online
  4. Conflict resolution — Handle cases where offline edits conflict with server changes

Sync strategies:

  • Full sync: Download all data every time. Simple but wasteful for large datasets.
  • Delta sync: Only sync changes since last sync timestamp/token. Efficient.
  • Event sourcing: Store events (created, updated, deleted) instead of state. Most flexible.
  • CRDT (Conflict-free Replicated Data Types): Data structures that automatically merge without conflicts.

Conflict resolution approaches:

  1. Last-write-wins (LWW): Simplest. Whoever saved last overwrites. Data can be lost.
  2. Server-wins: Server version always takes precedence. Client re-fetches.
  3. Client-wins: Client version overwrites server. Risk of overwriting other users' changes.
  4. Manual merge: Show conflict to user, let them choose. Best for critical data.
  5. Three-way merge: Compare both versions with a common ancestor. Git-style.

WorkManager for reliable sync: Survives process death, respects battery constraints, exponential backoff.

💻 Code Example

codeTap to expand ⛶
1// Offline-first sync architecture
2
3// 1. Entity with sync metadata
4@Entity(tableName = "documents")
5data class DocumentEntity(
6 @PrimaryKey val id: String,
7 val title: String,
8 val content: String,
9 val localVersion: Int = 1,
10 val serverVersion: Int = 0,
11 val syncState: String = "PENDING", // SYNCED, PENDING, CONFLICT
12 val modifiedAt: Long = System.currentTimeMillis(),
13 val isDeleted: Boolean = false // Soft delete for sync
14)
15
16// 2. DAO with sync-aware queries
17@Dao
18interface DocumentDao {
19 @Query("SELECT * FROM documents WHERE isDeleted = 0 ORDER BY modifiedAt DESC")
20 fun getAll(): Flow<List<DocumentEntity>>
21
22 @Query("SELECT * FROM documents WHERE syncState = 'PENDING'")
23 suspend fun getPendingSync(): List<DocumentEntity>
24
25 @Upsert
26 suspend fun upsert(doc: DocumentEntity)
27
28 @Query("UPDATE documents SET isDeleted = 1, syncState = 'PENDING' WHERE id = :id")
29 suspend fun softDelete(id: String) // Soft delete, sync later
30}
31
32// 3. Repository with optimistic updates
33class DocumentRepository @Inject constructor(
34 private val dao: DocumentDao,
35 private val api: DocumentApi,
36 private val workManager: WorkManager
37) {
38 fun getDocuments(): Flow<List<Document>> =
39 dao.getAll().map { it.map(DocumentEntity::toDomain) }
40
41 suspend fun saveDocument(doc: Document) {
42 // Optimistic: save locally FIRST, UI updates instantly
43 dao.upsert(doc.toEntity().copy(
44 syncState = "PENDING",
45 localVersion = doc.localVersion + 1,
46 modifiedAt = System.currentTimeMillis()
47 ))
48 // Schedule background sync
49 scheduleSyncWork()
50 }
51
52 private fun scheduleSyncWork() {
53 val work = OneTimeWorkRequestBuilder<SyncWorker>()
54 .setConstraints(Constraints.Builder()
55 .setRequiredNetworkType(NetworkType.CONNECTED)
56 .build())
57 .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
58 .build()
59 workManager.enqueueUniqueWork("doc_sync", ExistingWorkPolicy.REPLACE, work)
60 }
61}
62
63// 4. Sync Worker with conflict handling
64class SyncWorker @AssistedInject constructor(
65 @Assisted context: Context,
66 @Assisted params: WorkerParameters,
67 private val dao: DocumentDao,
68 private val api: DocumentApi
69) : CoroutineWorker(context, params) {
70
71 override suspend fun doWork(): Result {
72 val pending = dao.getPendingSync()
73 for (doc in pending) {
74 try {
75 val serverDoc = api.sync(doc.toSyncRequest())
76 if (serverDoc.version > doc.localVersion) {
77 // Conflict! Server has newer version
78 dao.upsert(doc.copy(syncState = "CONFLICT"))
79 } else {
80 dao.upsert(doc.copy(
81 syncState = "SYNCED",
82 serverVersion = serverDoc.version
83 ))
84 }
85 } catch (e: Exception) {
86 return if (runAttemptCount < 3) Result.retry() else Result.failure()
87 }
88 }
89 return Result.success()
90 }
91}

🏋️ Practice Exercise

Practice:

  1. Implement a complete offline-first CRUD app with sync using Room + WorkManager
  2. Design a conflict resolution UI that shows both versions side-by-side
  3. Implement delta sync using a server-provided sync token
  4. Test offline behavior: disable network, make changes, re-enable and verify sync
  5. Handle the edge case: user deletes a document offline, another user edits it online

⚠️ Common Mistakes

  • Treating network as the source of truth — the app breaks immediately when offline

  • Not using soft deletes for synced entities — hard deletes can't be synced

  • Ignoring conflict resolution — silently overwriting data leads to data loss

  • Not using WorkManager for sync — manual sync in a Service gets killed by the OS

  • Syncing everything instead of delta sync — wastes bandwidth and battery

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for Offline-First Architecture. Login to unlock this feature.