Phones lose signal in lifts, on rural roads, in school compounds with unreliable data, and during network outages. An app that shows a spinner or an error screen whenever the network drops feels broken, even when the user's own data is sitting safely on the device. An offline-first app works the other way round: it treats the local database as the source of truth, shows what it has immediately, and synchronises with the server when it can.
This lesson is the practical capstone of the data-layer lessons in the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture). It follows the official guide Build an offline-first app (https://developer.android.com/topic/architecture/data-layer/offline-first) and uses Room, Retrofit and WorkManager (https://developer.android.com/topic/libraries/architecture/workmanager).
By the end you will be able to:
- Explain offline-first and why the database is the source of truth.
- Choose read and write strategies, including queued writes.
- Design sync with WorkManager: constraints, retries, back-off and unique work.
- Pick a conflict resolution policy and understand its trade-offs.
- Monitor connectivity with a
Flow. - Show sync status honestly in the UI.
- Test the sync logic.
Big idea: the UI never waits for the network. It reads the database, and the network is just one of the things that updates the database.
Honesty note: version numbers are placeholders ("latest stable"). Room 3 imports are used, as in lesson 34; change them to
androidx.roomfor Room 2.x. The server in this lesson is imaginary; the sync logic is real and testable with fakes.
- Room, DAOs and Flow (lesson 34).
- Repositories, data sources and error handling (lesson 62).
- UI state production (lesson 61).
- Retrofit (lesson 29).
- Coroutines and Flow (lesson 33).
Not just a cache
A cache speeds things up but the network is still in charge: if there is no cache entry, nothing shows. In an offline-first app, the database is complete enough to use on its own, and the network exists to keep it fresh and to send local changes upstream.
┌───────────────┐
│ UI │ reads ONLY from the repository
└───────┬───────┘
│ Flow
┌───────▼───────┐
│ Repository │
└───┬───────┬───┘
reads │ │ writes (and a sync request)
┌───▼───┐ ┌─▼────────────┐
│ DB │ │ Sync worker │──► Network
│ (SSOT)│◄┤ (WorkManager)│◄── Network
└───────┘ └──────────────┘
Why it is worth the effort
| Benefit | Explanation |
|---|---|
| Works without a connection | Users can read and change data offline |
| Instant UI | Screens render from disk, not after a round trip |
| Resilient | Flaky networks are normal, not an error state |
| Saves data and battery | Fewer, batched requests |
| Simple UI code | The UI has a single flow to observe |
The cost is sync logic: you must reason about what happens when the server and the device disagree.
A teacher records attendance for each class. Requirements:
- It must work in a classroom with no network.
- Records are uploaded when a connection is available.
- The teacher can see which records are not yet uploaded.
- The server may also contain records entered by other teachers; they must appear.
Models and states
Each local record has a sync state:
enum class SyncState { SYNCED, PENDING_UPLOAD, FAILED }
@Entity(tableName = "attendance")
data class AttendanceEntity(
@PrimaryKey val id: String, // client-generated UUID, so records can be created offline
val studentName: String,
val date: String, // ISO-8601 date
val present: Boolean,
val updatedAtMillis: Long, // used for conflict resolution
val syncState: SyncState
)
Key design choices:
- Client-generated ids (UUIDs): an offline device cannot ask the server for an id. A UUID lets the client create records safely, and the server accepts them.
updatedAtMillis: when the record was last edited, used to decide who wins in conflicts.syncState: a row inPENDING_UPLOADis a change the server has not seen. The enum needs a type converter in Room (store the name as a string). In Room 3 this is a@ColumnTypeConverter; in Room 2.x it is@TypeConverter(see lesson 34).
class SyncStateConverters {
@ColumnTypeConverter
fun toString(value: SyncState): String = value.name
@ColumnTypeConverter
fun fromString(value: String): SyncState = SyncState.valueOf(value)
}
(Register it with @TypeConverters(SyncStateConverters::class) on the database class. Check the Room 3 documentation for the exact annotation names.)
The DAO
@Dao
interface AttendanceDao {
@Query("SELECT * FROM attendance WHERE date = :date ORDER BY studentName")
fun observeForDate(date: String): Flow<List<AttendanceEntity>>
@Query("SELECT * FROM attendance WHERE syncState != 'SYNCED'")
suspend fun pendingChanges(): List<AttendanceEntity>
@Query("SELECT COUNT(*) FROM attendance WHERE syncState = 'PENDING_UPLOAD'")
fun observePendingCount(): Flow<Int>
@Upsert
suspend fun upsert(item: AttendanceEntity)
@Upsert
suspend fun upsertAll(items: List<AttendanceEntity>)
@Query("SELECT * FROM attendance WHERE id = :id")
suspend fun find(id: String): AttendanceEntity?
@Query("UPDATE attendance SET syncState = 'SYNCED' WHERE id IN (:ids)")
suspend fun markSynced(ids: List<String>)
}
The UI reads only from the database:
interface AttendanceRepository {
fun observeForDate(date: String): Flow<List<AttendanceRecord>>
fun observePendingCount(): Flow<Int>
suspend fun mark(studentName: String, date: String, present: Boolean)
suspend fun syncNow(): Result<Unit>
}
class OfflineFirstAttendanceRepository(
private val dao: AttendanceDao,
private val api: AttendanceApi,
private val syncScheduler: SyncScheduler,
private val clock: () -> Long = System::currentTimeMillis,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : AttendanceRepository {
override fun observeForDate(date: String): Flow<List<AttendanceRecord>> =
dao.observeForDate(date)
.map { list -> list.map { it.toModel() } }
.flowOn(ioDispatcher)
override fun observePendingCount(): Flow<Int> = dao.observePendingCount()
// mark() and syncNow() follow
}
No network call appears in the read path. Screens appear instantly, from disk, whether or not the device is online.
When the teacher taps "present":
- Write to the database immediately, marked
PENDING_UPLOAD. - The UI updates at once because it observes the database.
- Schedule a sync that uploads pending changes when a connection exists.
override suspend fun mark(studentName: String, date: String, present: Boolean) {
withContext(ioDispatcher) {
val id = "$date|$studentName" // stable id for one student on one date
dao.upsert(
AttendanceEntity(
id = id,
studentName = studentName,
date = date,
present = present,
updatedAtMillis = clock(),
syncState = SyncState.PENDING_UPLOAD
)
)
}
syncScheduler.requestSync()
}
The method returns as soon as the local write finishes, so there is no spinner, and the user cannot lose their work by walking out of coverage. The word queued means the database row itself is the queue: pending rows are the work to do.
A stable id ("$date|$studentName") means marking the same student twice on the same day updates one row rather than creating a duplicate. Choose ids that make your operations idempotent (safe to repeat). Idempotence is essential, because retries mean the server may see the same upload more than once.
Why WorkManager
WorkManager runs deferrable, guaranteed background work: it survives app restarts and device reboots, respects constraints (such as network availability) and retries with back-off. It is the recommended tool for sync. Do not use a bare coroutine, a Service you manage by hand, or GlobalScope: they die with the process.
Add the dependency (use the latest stable version; recent releases include the Kotlin extensions in the main artifact, older ones need work-runtime-ktx):
[versions]
work = "<latest stable>"
[libraries]
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }
The worker
class SyncWorker(
appContext: Context,
params: WorkerParameters,
private val repository: AttendanceRepository
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val outcome = repository.syncNow()
return when {
outcome.isSuccess -> Result.success()
runAttemptCount < 5 -> Result.retry() // WorkManager applies back-off
else -> Result.failure()
}
}
}
CoroutineWorker gives you a suspend doWork. Returning Result.retry() asks WorkManager to run it again later with exponential back-off. (Inside doWork the name Result means ListenableWorker.Result; the repository's Result<Unit> is Kotlin's. Import carefully or alias one of them.)
A word on constructor injection: WorkManager creates workers itself, so passing repository through the constructor needs a WorkerFactory (or Hilt's @HiltWorker, lesson 65). A simple manual factory:
class AppWorkerFactory(private val repository: AttendanceRepository) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? = when (workerClassName) {
SyncWorker::class.java.name -> SyncWorker(appContext, workerParameters, repository)
else -> null
}
}
Register it by implementing Configuration.Provider in your Application class and returning a Configuration that uses the factory (the official WorkManager documentation shows the current form).
The scheduler
interface SyncScheduler { fun requestSync() }
class WorkManagerSyncScheduler(private val context: Context) : SyncScheduler {
override fun requestSync() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"attendance-sync",
ExistingWorkPolicy.KEEP, // if a sync is already queued, do not queue another
request
)
}
}
- Constraint: only run when the network is connected. WorkManager waits for it, even across reboots.
- Unique work with
KEEP: twenty quick taps cause one upload, because the sync reads all pending rows. - Back-off: if the server is down, retries become gradually less frequent.
You can also schedule a periodic pull so that the device receives other teachers' changes:
val periodic = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build())
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("attendance-periodic", ExistingPeriodicWorkPolicy.KEEP, periodic)
(Periodic work has a minimum interval of 15 minutes. UNMETERED means Wi-Fi, which spares mobile data; choose what suits your users.)
A robust sync is push then pull: upload local changes first, then download the server's.
override suspend fun syncNow(): Result<Unit> = withContext(ioDispatcher) {
runCatchingNetwork {
// 1. PUSH: send everything pending
val pending = dao.pendingChanges()
if (pending.isNotEmpty()) {
api.upload(pending.map { it.toDto() })
dao.markSynced(pending.map { it.id })
}
// 2. PULL: fetch changes from the server and merge
val remote = api.changesSince(lastSyncToken())
mergeRemote(remote)
saveSyncToken(remote.nextToken)
}
}
Note: if the upload throws, nothing is marked as synced, so the next run re-uploads the same rows. That is why the server operation must be idempotent (upsert by id).
A careful detail: rows edited while the upload is in flight. If the user changes a row after pendingChanges() reads it but before markSynced runs, marking it synced would hide a newer change. Fix it by marking synced only if the row has not changed since:
@Query("UPDATE attendance SET syncState = 'SYNCED' WHERE id = :id AND updatedAtMillis = :version")
suspend fun markSyncedIfUnchanged(id: String, version: Long)
and calling it for each uploaded item with its updatedAtMillis. Otherwise the newer edit would never upload.
Conflict resolution
A conflict happens when the same record was changed on the device and on the server. You must pick a policy.
| Policy | Rule | Good for | Risk |
|---|---|---|---|
| Server wins | Remote overwrites local | Reference data (class lists) | Lose offline edits |
| Client wins | Local overwrites remote | Single-user personal data | Lose other users' edits |
| Last write wins | Compare timestamps | Simple collaboration | Clock differences; silent loss |
| Field-level merge | Merge different fields | Records with independent fields | More code |
| Ask the user | Show both versions | Critical data | More UI |
A simple last-write-wins merge, protecting local pending edits:
private suspend fun mergeRemote(remote: List<AttendanceDto>) {
for (dto in remote) {
val local = dao.find(dto.id)
val keepLocal = local != null &&
local.syncState == SyncState.PENDING_UPLOAD &&
local.updatedAtMillis > dto.updatedAtMillis
if (!keepLocal) dao.upsert(dto.toEntity(SyncState.SYNCED))
}
}
Be honest about the limit: device clocks can be wrong, so timestamps are a heuristic. For data where silent loss matters, use server-assigned versions (an incrementing version or ETag) and have the server reject stale writes, then let the client re-fetch and retry or ask the user.
Deletes
You cannot simply delete a row if you need to tell the server. Use a tombstone: mark the row deleted = true, sync it, then really remove it after the server confirms.
Observing connectivity
Connectivity is a hint, not a guarantee (a device can be "connected" to Wi-Fi with no internet). Still, it is useful for showing a banner. Observe it as a Flow:
class ConnectivityObserver(context: Context) {
private val manager = context.getSystemService(ConnectivityManager::class.java)
val isOnline: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
manager.registerDefaultNetworkCallback(callback)
trySend(manager.activeNetwork != null)
awaitClose { manager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
(This needs the ACCESS_NETWORK_STATE permission in the manifest.)
Telling the user the truth
data class AttendanceUiState(
val records: List<AttendanceRecord> = emptyList(),
val pendingCount: Int = 0,
val online: Boolean = true
)
class AttendanceViewModel(
repository: AttendanceRepository,
connectivity: ConnectivityObserver,
private val date: String
) : ViewModel() {
val uiState: StateFlow<AttendanceUiState> = combine(
repository.observeForDate(date),
repository.observePendingCount(),
connectivity.isOnline
) { records, pending, online -> AttendanceUiState(records, pending, online) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AttendanceUiState())
}
@Composable
fun SyncBanner(pending: Int, online: Boolean) {
val text = when {
pending > 0 && !online -> "$pending changes saved on this phone. They will upload when you are online."
pending > 0 -> "Uploading $pending changes…"
!online -> "Offline. Showing saved data."
else -> return
}
Surface(color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.fillMaxWidth()) {
Text(text, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
The words matter. "Saved on this phone" reassures the teacher that nothing is lost; "Offline" without more would worry them.
Because the sync logic is in the repository and the database and API are replaceable, you can test it with fakes:
class SyncTest {
private val dao = FakeAttendanceDao()
private val api = FakeAttendanceApi()
private val scheduler = object : SyncScheduler { var calls = 0; override fun requestSync() { calls++ } }
@Test
fun mark_saves_locally_as_pending_and_requests_sync() = runTest {
val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
repo.mark("Ada", "2026-10-26", present = true)
val row = dao.find("2026-10-26|Ada")!!
assertEquals(SyncState.PENDING_UPLOAD, row.syncState)
assertEquals(1, scheduler.calls)
}
@Test
fun failed_upload_keeps_rows_pending() = runTest {
api.failUploads = true
val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
repo.mark("Ada", "2026-10-26", true)
val result = repo.syncNow()
assertTrue(result.isFailure)
assertEquals(SyncState.PENDING_UPLOAD, dao.find("2026-10-26|Ada")!!.syncState)
}
@Test
fun newer_local_edit_survives_older_remote_copy() = runTest {
dao.upsert(AttendanceEntity("d|Ada", "Ada", "d", true, updatedAtMillis = 2_000, syncState = SyncState.PENDING_UPLOAD))
api.remote = listOf(AttendanceDto("d|Ada", "Ada", "d", present = false, updatedAtMillis = 1_000))
val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 3_000 }, StandardTestDispatcher(testScheduler))
repo.syncNow()
assertTrue(dao.find("d|Ada")!!.present)
}
}
To test the worker itself, the androidx.work:work-testing artifact provides TestListenableWorkerBuilder, which runs doWork directly and lets you assert on Result.success(), Result.retry() and Result.failure(). On a device, adb shell cmd connectivity airplane-mode enable (on supported versions) or the emulator's network controls let you try the full flow: mark attendance offline, watch the banner, turn the network on, and watch the pending count drop to zero.
Exercise 1: Why client-generated ids?
A teammate suggests letting the server assign ids and waiting for the response before showing a new record. Give two problems with that in an offline-first app.
(1) With no connection there is no response, so the record cannot be created at all, which defeats offline-first. (2) Waiting for the network blocks the UI and makes the record appear late. Client-generated ids (UUIDs or deterministic keys) let the device create records immediately, and the server accepts them. Deterministic keys also make retries idempotent.
Exercise 2: Pick a policy
For each, choose a conflict policy: (a) a class list maintained by the school office; (b) a teacher's personal notes on their own device and account; (c) a shared marks sheet edited by several teachers.
(a) Server wins, because the office owns the data and devices should not overwrite it. (b) Client wins (or last write wins) since only one person edits it. (c) Field-level merge or server-assigned versions with "ask the user", because silent loss of a teacher's marks would be serious.
Exercise 3: Find the race
syncNow reads pending rows, uploads them, then marks all those rows as synced. Describe a bug when the user edits a row during the upload and give the fix.
The edited row is overwritten as SYNCED after the upload, though the server only has the old version, so the new edit is never uploaded. Fix: mark a row synced only if its version (updatedAtMillis or a counter) is unchanged since it was read, using UPDATE ... WHERE id = :id AND updatedAtMillis = :version. Rows that changed stay PENDING_UPLOAD and go out in the next sync.
Mistake #1: Reading from the network in the UI path. Fix: the UI reads only from the database.
Mistake #2: Waiting for the server before showing a write. Fix: write locally first, then sync.
Mistake #3: Server-assigned ids for offline-created records. Fix: use UUIDs or deterministic keys.
Mistake #4: Non-idempotent uploads. Fix: upsert by id on the server.
Mistake #5: Using a coroutine or service instead of WorkManager. Fix: use WorkManager with constraints.
Mistake #6: Deleting local data when a refresh fails. Fix: change data only after success.
Mistake #7: Marking rows synced after concurrent edits. Fix: compare versions.
Mistake #8: Trusting device clocks completely. Fix: prefer server versions where loss matters.
Mistake #9: Hiding sync state from users. Fix: show pending counts and clear wording.
Mistake #10: Queuing a new job for every tap. Fix: unique work with KEEP.
Question 1. What is the difference between a cache and an offline-first design?
A cache accelerates a network-driven app; in offline-first, the database is the complete source of truth the UI uses, and the network only syncs it.
Question 2. Why does enqueueUniqueWork with KEEP help?
It prevents duplicate queued syncs; one run uploads all pending rows.
Question 3. What is idempotence and why does sync need it?
An operation that gives the same result when repeated. Retries and re-uploads are normal, so the server must handle the same record twice safely.
Question 4. Name two conflict policies and a risk of each.
Server wins (loses offline edits) and last write wins (clock errors can lose newer data); others include client wins and field-level merge.
Question 5. Which WorkManager feature delays work until the network is available?
Constraints, specifically setRequiredNetworkType(NetworkType.CONNECTED).
Go deeper: official resources
- Modern Android App Architecture programme
- Build an offline-first app
- Data layer
- Recommendations for Android architecture
Project goal: Build the attendance app from this lesson end to end, and prove it works with the network off.
Requirements:
- Room database with
AttendanceEntity,SyncStateand the DAO (use Room 3 or 2.x consistently). - An
AttendanceApi(Retrofit, or a fake in-memory server withdelay) withuploadandchangesSince. OfflineFirstAttendanceRepositorywithobserveForDate,observePendingCount,markandsyncNow.- A
SyncWorkerwith constraints, back-off and unique work, plus a worker factory. - A last-write-wins merge that protects newer local edits, and version-checked
markSynced. - A
ConnectivityObserverand aSyncBannershowing honest messages. - A screen listing students for today with present/absent toggles.
- At least four tests: local write is pending, failed upload keeps pending, merge protects newer local edits, and concurrent edit during upload stays pending.
- A manual test script (written down) for airplane mode: mark five students offline, restart the app, go online, confirm the count reaches zero.
Stretch goal: implement tombstone deletes and show a "Retry" button for rows in FAILED state.
Version-checked marking, used in syncNow:
val pending = dao.pendingChanges()
if (pending.isNotEmpty()) {
api.upload(pending.map { it.toDto() })
pending.forEach { dao.markSyncedIfUnchanged(it.id, it.updatedAtMillis) }
}
A concurrency test:
@Test
fun edit_during_upload_stays_pending() = runTest {
val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
repo.mark("Ada", "d", true)
api.onUpload = {
// simulate a new edit arriving while the upload is running
dao.upsert(dao.find("d|Ada")!!.copy(present = false, updatedAtMillis = 2_000, syncState = SyncState.PENDING_UPLOAD))
}
repo.syncNow()
val row = dao.find("d|Ada")!!
assertEquals(SyncState.PENDING_UPLOAD, row.syncState)
assertFalse(row.present)
}
The manual airplane-mode script (example):
1. Install, open, switch the phone to airplane mode.
2. Mark 5 students present. Banner: "5 changes saved on this phone..."
3. Force-stop and reopen the app. The 5 marks are still there.
4. Switch airplane mode off. Banner: "Uploading 5 changes..."
5. After a few seconds the banner disappears; pending count = 0.
6. Check the (fake or real) server: 5 records present.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 64 project”.
On github.com click New repository, name it android-lesson-64-build-an-offline-first-app, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 64 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Offline-First
- Step-by-Step Build 1: The Running Example, "RegisterSync"
- Step-by-Step Build 2: Read Strategy
- Step-by-Step Build 3: Write Strategy (Queued Writes)
- Step-by-Step Build 4: Sync with WorkManager
- Step-by-Step Build 5: The Sync Algorithm
- Step-by-Step Build 6: Connectivity and Honest UI
- Step-by-Step Build 7: Testing Sync
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check