Build an offline-first Android MVVM architecture with Room, a transactional outbox, and WorkManager. Learn conflict handling, benchmark-driven validation, and release checks for durable sync.
Android MVVM Architecture for Offline-First Sync That Survives Restarts
Offline-first android mvvm architecture: make Room authoritative
A durable sync design starts with one rule: UI reads only from Room, never directly from a Retrofit response. In an android mvvm architecture, the repository writes a remote snapshot and its sync cursor in one Room transaction; a ViewModel exposes the resulting Flow. This prevents a subtle split-brain failure where a jetpack compose screen renders fresh network data, then reverts after process recreation because the database was not updated. In android training and kotlin training exercises, verify this by enabling airplane mode after a successful refresh, killing the process with adb shell am force-stop your.package, and confirming that the same list is rendered after relaunch.
class DocumentRepository(
private val db: AppDatabase,
private val api: DocumentsApi
) {
val documents: Flow<List<Document>> = db.documentDao().observeAll()
suspend fun refresh() {
val cursor = db.syncStateDao().get("documents")?.cursor
val response = api.pullDocuments(cursor)
db.withTransaction {
db.documentDao().upsertAll(response.documents)
db.syncStateDao().upsert(
SyncState(key = "documents", cursor = response.nextCursor)
)
}
}
}
class DocumentsViewModel(repository: DocumentRepository) : ViewModel() {
val documents = repository.documents
.map { docs -> DocumentsUiState.Ready(docs) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DocumentsUiState.Loading)
}Do not store only an isDirty boolean for local edits. Persist an outbox row containing an immutable operation ID, entity ID, operation type, JSON payload, local sequence number, and retry state in the same transaction that changes the visible entity. The transaction is the mechanism that eliminates the crash window between “the user saw the edit” and “the edit became syncable.” A useful schema has a unique index on operationId, not on entityId: multiple edits to the same document must remain distinguishable unless the server explicitly supports operation compaction.
For Compose, collect the Room Flow with collectAsStateWithLifecycle() from androidx.lifecycle:lifecycle-runtime-compose, and send user intents back to the ViewModel rather than mutating a composable-local copy of a document. A common experienced-team mistake is to use a local remember { mutableStateOf(document) } editor and overwrite a newer database emission when the user presses Save. Keep an edit draft keyed by entity ID, compare its base revision to the persisted revision, and show a conflict state when they differ.
WorkManager sync patterns for a mobile app development course
A mobile app development course should model synchronization as an outbox drain, not as “upload the current screen.” Schedule one unique chain after inserting an outbox operation, and let the worker query pending rows from Room. ExistingWorkPolicy.KEEP prevents a burst of edits from creating parallel workers that upload the same queue; the worker must still loop until the queue is empty because a new operation can arrive while it is running.
fun scheduleSync(context: Context) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30, TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"outbox-sync",
ExistingWorkPolicy.KEEP,
request
)
}
class SyncWorker(
appContext: Context,
params: WorkerParameters,
private val repository: SyncRepository
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = try {
repository.drainOutbox()
Result.success()
} catch (e: IOException) {
Result.retry()
} catch (e: HttpException) {
if (e.code() in 500..599) Result.retry() else Result.failure()
}
}Make every write request idempotent by sending the persisted operation ID as an Idempotency-Key header. If a request reaches the server but the response is lost, WorkManager correctly retries; without server-side deduplication, that retry can create a duplicate comment, payment, or audit event. On the server, store the key with the completed response for a retention window and return that response on repeated keys. Treat HTTP 409 differently from a transport failure: fetch the canonical entity, run a domain-specific merge, create a replacement outbox operation, and mark the old operation resolved rather than retrying it forever.
Use WorkManager constraints for eligibility, not as a guarantee that work begins immediately. The connected-network constraint is reevaluated by the scheduler, and background execution can be delayed by the operating system. If an explicit user action needs quick feedback, update Room optimistically, show an “Uploading” state from the outbox row, and optionally request expedited work while handling quota fallback. Inspect actual scheduling with adb shell dumpsys jobscheduler | grep your.package; do not infer worker execution from a loading spinner.
android studio training: benchmark sync before changing it
For practical android studio training, establish two measurements before tuning: cold-start time to first database-rendered content, and the number of network requests generated by ten offline edits followed by reconnection. Use the Android Studio Network Inspector for debug builds to confirm that the reconnect sequence is one pull plus the expected outbox requests, rather than one refresh per recomposition. Save a before/after table in the pull request; a change that reduces requests but increases time-to-first-content is not automatically an improvement.
Use Macrobenchmark to measure a process-start path against a seeded Room database. The important detail is to use CompilationMode.Full() for a stable CI signal and separately test a realistic compilation mode for user-facing behavior. Benchmark the first frame containing cached content, not merely activity launch completion.
@RunWith(AndroidJUnit4::class)
class DocumentsStartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test
fun startupShowsCachedDocuments() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.Full(),
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = {
pressHome()
}
) {
startActivityAndWait()
device.wait(Until.hasObject(By.text("Cached document")), 5_000)
}
}Profile database work with the Android Studio CPU Profiler or Perfetto, then look specifically for repeated DAO queries caused by mapping each list item independently. A typical correction is to expose one SQL join or a Room relation query for the visible list rather than issuing a query from every row. Also enable Room query logging in debug builds with Room.databaseBuilder(...).setQueryCallback(...). This catches the less obvious issue: Flow invalidation reruns a broad query whenever an unrelated column update touches the observed table.
play store publishing checks for an offline sync client
Before play store publishing, test the release artifact rather than relying on a debug build. Build an Android App Bundle with ./gradlew :app:bundleRelease, install its generated APK set through bundletool build-apks --bundle app-release.aab --output app.apks and bundletool install-apks --apks app.apks, then repeat the force-stop and offline-edit scenarios. Release-only shrinking can remove reflection-dependent serializers or WorkManager worker constructors; use generated serializers where possible and add narrowly scoped R8 keep rules only when a library requires them.
Keep credentials and short-lived session material out of Room backups. Define data extraction rules so a device-to-device restore does not resurrect an outbox whose authorization context no longer exists, then force a new authenticated sync after restore. For example, exclude the database file if its content is account-scoped and not safely revalidated:
<!-- res/xml/data_extraction_rules.xml -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="database" path="app.db" />
</cloud-backup>
<device-transfer>
<exclude domain="database" path="app.db" />
</device-transfer>
</data-extraction-rules>
<!-- AndroidManifest.xml application attribute -->
android:dataExtractionRules="@xml/data_extraction_rules"Treat your store data declaration as an engineering artifact: enumerate each request payload and local table, then document whether document text, identifiers, or diagnostics leave the device. Run an internal-track test with a deliberately expired token and an old queued operation. The expected behavior is not an endless retry: mark the operation as requiring sign-in, retain its user-visible context, and avoid transmitting it under a different account after reauthentication.
Turn an android course project into a failure-testable sync system
A strong android course capstone can use a deterministic fake server such as MockWebServer to reproduce failures that are difficult to catch manually: return 500 after recording an idempotency key, return 409 with a newer revision, and disconnect mid-response. Assert database state, not only emitted UI text: after a simulated lost response, there should be one server-side operation and zero pending outbox rows after retry. This gives the project a testable definition of exactly-once user intent, even though the network itself remains at-least-once.
Add an instrumentation test that disables network through an OkHttp interceptor or a fake API, writes two edits, recreates the activity with ActivityScenario.recreate(), restores connectivity, and waits with WorkManager's testing library. In test code, use WorkManagerTestInitHelper plus a TestDriver to satisfy constraints instead of sleeping. Sleeping hides races; explicitly advancing the scheduler proves that the operation survived database persistence, process recreation, and deferred execution.
Related Course
Related YTUSEM Program
Android Kotlin Program (Yildiz Technical University, Istanbul - Continuing Education Center)
Frequently Asked Questions
How should android mvvm architecture handle offline edits?
Write the entity mutation and an immutable outbox operation in one Room transaction. Expose the entity through a Room Flow to the ViewModel, then have a unique WorkManager job drain the outbox. Do not let the ViewModel hold the only copy of an unsynced edit.
What should an android training project use for reliable background sync?
Use WorkManager with a CONNECTED network constraint, exponential backoff for IOException and 5xx responses, and a persistent Room outbox. Add an idempotency key to every server mutation; WorkManager retries are expected and must not duplicate server-side effects.
How can android studio training measure whether offline sync is efficient?
Use Network Inspector to count reconnect requests, Macrobenchmark to measure cold start until cached Room content appears, and Perfetto or CPU Profiler to locate repeated DAO work. Compare a fixed scenario, such as ten offline edits and one reconnect, before and after each change.
What should be tested before play store publishing an offline-first app?
Install the release AAB-derived APK set, test force-stop recovery with queued edits, expired authentication, 409 conflicts, and a lost HTTP response after the server accepted a request. Also review backup rules so account-scoped databases and queued operations are not restored into an unrelated session.
AI / LLM Discovery
This article is part of Opendart Akademi's Android training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.


