Skip to content
All posts
March 28, 20264 min read

Building Offline-First Android Apps: Architecture and Sync Strategies

Offline-first means the app works without a network connection and syncs when one is available. It's the right architecture for most mobile apps but requires deliberate design. Here's how to build it with Room, WorkManager, and conflict resolution.

AndroidArchitectureDataKotlin
Share:

"Offline-first" means the app treats the local database as the source of truth. Network is optional — a way to sync state, not a dependency for basic functionality.

Most apps are the opposite: they treat the network as the source of truth and break when it's not available. Building offline-first requires deliberate decisions at every layer.


The Core Architecture

code
User Action
    ↓
ViewModel
    ↓
Repository
    ├── Local: Room (immediate write, immediate read)
    └── Remote: API (background sync, eventual consistency)
    
UI ← Flow from Room ← Always current, even offline

The key: the UI observes Room via Flow, not the API response. API calls update Room, which triggers Flow updates.


Room as the Single Source of Truth

kotlin
class TaskRepositoryImpl(
    private val taskDao: TaskDao,
    private val taskApiService: TaskApiService,
    private val syncManager: SyncManager
) : TaskRepository {
    
    // Returns Flow from Room — always current
    override fun observeTasks(): Flow<List<Task>> =
        taskDao.observeAllTasks().map { entities -> entities.map { it.toDomain() } }
    
    override suspend fun createTask(task: Task) {
        // 1. Write to local immediately (user sees it right away)
        val entity = task.toEntity(syncStatus = SyncStatus.PENDING)
        taskDao.insert(entity)
        
        // 2. Try to sync to server in the background
        syncManager.scheduleSyncIfNeeded()
    }
}

The user sees their new task immediately. Syncing happens in the background.


Tracking Sync Status

Add sync state to your database entities:

kotlin
enum class SyncStatus {
    SYNCED,    // Matches server
    PENDING,   // Created locally, not yet sent to server
    MODIFIED,  // Modified locally, not yet synced
    DELETED    // Marked for deletion, not yet synced
}

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey val id: String,
    val title: String,
    val completed: Boolean,
    val syncStatus: SyncStatus = SyncStatus.SYNCED,
    val localModifiedAt: Long = 0
)

Your sync manager queries for pending/modified/deleted entities and sends them to the server.


WorkManager for Background Sync

WorkManager is the right tool for guaranteed background work — it runs when network is available and retries on failure:

kotlin
class SyncWorker(
    context: Context,
    params: WorkerParameters,
    private val taskRepository: TaskRepository
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        return try {
            taskRepository.syncPendingChanges()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry()
            else Result.failure()
        }
    }
    
    companion object {
        fun createSyncRequest(): OneTimeWorkRequest {
            return OneTimeWorkRequestBuilder<SyncWorker>()
                .setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                .setBackoffCriteria(
                    BackoffPolicy.EXPONENTIAL,
                    15, TimeUnit.MINUTES
                )
                .build()
        }
        
        fun createPeriodicSyncRequest(): PeriodicWorkRequest {
            return PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
                .setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                .build()
        }
    }
}

Schedule it:

kotlin
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "task_sync",
    ExistingPeriodicWorkPolicy.KEEP, // Don't replace existing scheduled work
    SyncWorker.createPeriodicSyncRequest()
)

The Sync Flow

kotlin
class TaskSyncService(
    private val taskDao: TaskDao,
    private val apiService: TaskApiService
) {
    suspend fun syncPendingChanges() {
        // 1. Push local changes to server
        val pending = taskDao.getByStatus(SyncStatus.PENDING)
        val modified = taskDao.getByStatus(SyncStatus.MODIFIED)
        val deleted = taskDao.getByStatus(SyncStatus.DELETED)
        
        pending.forEach { entity ->
            val dto = apiService.createTask(entity.toDto())
            taskDao.update(entity.copy(
                syncStatus = SyncStatus.SYNCED,
                id = dto.id // Server assigns canonical ID
            ))
        }
        
        modified.forEach { entity ->
            apiService.updateTask(entity.id, entity.toDto())
            taskDao.update(entity.copy(syncStatus = SyncStatus.SYNCED))
        }
        
        deleted.forEach { entity ->
            apiService.deleteTask(entity.id)
            taskDao.delete(entity.id)
        }
        
        // 2. Pull server changes
        val serverTasks = apiService.getTasks()
        taskDao.upsertAll(serverTasks.map { it.toEntity(syncStatus = SyncStatus.SYNCED) })
    }
}

Conflict Resolution

When a user edits a task offline and someone else edits the same task on the server — who wins?

Last-write wins: Compare

code
localModifiedAt
vs
code
serverModifiedAt
. The newer timestamp wins. Simple, but you can lose changes.

Client wins: Local change always takes precedence. Good for user-owned data (a personal task list).

Server wins: Server change always takes precedence. Good for shared data where server is authoritative.

Manual merge: For complex cases, surface the conflict to the user. "Your version vs server version — which do you want to keep?"

For most personal apps, client-wins is correct. For collaborative apps, last-write-wins with conflict surfacing is better.


Showing Sync Status in the UI

Users should know when they're offline and when changes are pending:

kotlin
@Composable
fun SyncStatusBar(syncStatus: SyncStatus) {
    AnimatedVisibility(visible = syncStatus != SyncStatus.SYNCED) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .background(Color(0xFF3A3A3A))
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(
                text = when (syncStatus) {
                    SyncStatus.OFFLINE -> "You're offline — changes saved locally"
                    SyncStatus.SYNCING -> "Syncing..."
                    SyncStatus.ERROR -> "Sync failed — will retry"
                    else -> ""
                },
                color = Color.White,
                fontSize = 12.sp
            )
        }
    }
}

Takeaways

  • Room is the source of truth; the API syncs to Room, not the other way around
  • Tag entities with sync status (PENDING, MODIFIED, DELETED) to know what needs syncing
  • WorkManager handles background sync with retry and network constraints
  • Choose a conflict resolution strategy upfront — don't discover you need one in production
  • Show sync status in the UI — users need to know their data is safe even offline
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this