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.
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.
On this page
"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.
User Action
↓
ViewModel
↓
Repository
├── Local: Room (immediate write, immediate read)
└── Remote: API (background sync, eventual consistency)
UI ← Flow from Room ← Always current, even offlineThe key: the UI observes Room via Flow, not the API response. API calls update Room, which triggers Flow updates.
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.
Add sync state to your database entities:
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 is the right tool for guaranteed background work — it runs when network is available and retries on failure:
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:
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"task_sync",
ExistingPeriodicWorkPolicy.KEEP, // Don't replace existing scheduled work
SyncWorker.createPeriodicSyncRequest()
)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) })
}
}When a user edits a task offline and someone else edits the same task on the server — who wins?
Last-write wins: Compare
localModifiedAtserverModifiedAtClient 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.
Users should know when they're offline and when changes are pending:
@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
)
}
}
}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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read