Course outline

Android development / Advanced: reliable mobile systems and releases / Lesson 17

Offline-first synchronization and conflicts

Build a durable outbox that preserves user intent across disconnection, retries, and competing edits.

About 4 min readContent reviewed
On this page

Lesson goals

  • Model local edits and remote acknowledgment separately.
  • Define conflict, ordering, deletion, and deduplication policies.

Define what works without a network

Offline-first design means a defined set of core tasks works from local data, not that every remote feature is magically available. The UI reads the local source of truth immediately and exposes freshness and pending status honestly. An edit can be accepted locally while synchronization remains pending. Distinguish local acceptance from server confirmation so users understand the state. Authentication expiry, quota limits, and server validation can still prevent eventual acceptance, so the product needs a supported intervention path rather than an infinite promise that every queued change will succeed.

Commit the edit and intention together

An outbox stores pending operations beside local records. Each operation needs a stable id, account scope, target record id, payload or reference, and relevant base version. Write the local edit and outbox row in one transaction so a crash cannot separate them. Scheduling occurs after commit and can be retried by reconciliation if interrupted. Keep operation identity stable across worker restarts. The database state should be sufficient to resume without the previous activity, ViewModel, or network callback existing.

kotlin One transaction records data and intent
data class PendingEdit(
    val operationId: String, val accountId: String,
    val bookId: String, val title: String, val baseVersion: Long
)

suspend fun renameOffline(
    db: RoomDatabase, books: BooksDao, outbox: OutboxDao, edit: PendingEdit
) {
    db.withTransaction {
        books.rename(edit.bookId, edit.title)
        outbox.insert(edit)
    }
}

Use compatible androidx.room RoomDatabase and withTransaction APIs. BooksDao and OutboxDao are app-defined suspend DAO contracts and PendingEdit requires a corresponding entity mapping. Validate account ownership and title before this boundary; schedule only after successful commit.

Apply acknowledgment without erasing new edits

When the server accepts an operation, record its result and remove or complete that exact outbox operation atomically. A user may have edited the same title again while the first upload was in flight. Blindly replacing the local row with the first response can erase the newer intent. Track a local generation or pending chain and apply acknowledgments conditionally. A retry after an ambiguous timeout must reuse the same operation id with server-side deduplication. Local work uniqueness cannot provide that remote guarantee by itself.

Make conflicts a product decision

A base version allows the server to detect that another device changed the record. A conflict is not necessarily a temporary network failure and should not be retried unchanged forever. Choose a policy appropriate to the data: manual resolution for valuable notes, field-level merge where semantics permit it, or an explicitly documented last-writer rule for low-risk settings. Device wall clocks are unreliable ordering authorities. Deletions often need tombstones or versioned deletion records so a stale device cannot resurrect content merely by uploading an older copy.

kotlin Classify synchronization outcomes explicitly
sealed interface UploadOutcome {
    data class Accepted(val serverVersion: Long) : UploadOutcome
    data class Conflict(val serverTitle: String, val serverVersion: Long) : UploadOutcome
    data object RetryLater : UploadOutcome
    data object SignInRequired : UploadOutcome
}

fun needsUserAction(outcome: UploadOutcome): Boolean = when (outcome) {
    is UploadOutcome.Conflict, UploadOutcome.SignInRequired -> true
    is UploadOutcome.Accepted, UploadOutcome.RetryLater -> false
}

This Kotlin model uses data object syntax supported by modern Kotlin toolchains. The service maps protocol results into these outcomes; the repository stores their effects. RetryLater still requires bounded backoff, while conflict and sign-in need a separate resolution workflow.

Test interruption at every boundary

Test before local commit, after commit before scheduling, after server acceptance before acknowledgment, and after acknowledgment before UI observation. Add two-device conflicts, deletion followed by stale upload, account switching, and a queue larger than one worker can drain. Use deterministic fakes to control ordering and failures. Observe queue age, attempt count, permanent errors, and conflict count without logging private payloads. A successful sync indicator should reflect the relevant recordโ€™s durable acknowledgment, not merely the completion of one network call or an empty in-memory list.

Common errors and fixes

A newer title disappears after an older upload finishes

Why it happens
Acknowledgment blindly overwrites current local state.
How to fix it
Apply results only to the matching generation or operation chain.
Prevent it next time
Test editing while a previous write is in flight.

Deleted records return after another device syncs

Why it happens
Deletion has no versioned representation.
How to fix it
Use tombstones or an equivalent server deletion contract.
Prevent it next time
Test stale writers against deletions.

A conflict retries forever

Why it happens
Permanent semantic conflict is classified as transient I/O.
How to fix it
Store a conflict state and invoke the chosen resolution policy.
Prevent it next time
Separate transport failures from domain outcomes.

Check your understanding

1. A rename is committed locally, then the process dies before WorkManager is notified. Explain recovery.

Show worked solution

The outbox row remains because it was committed with the edit. On the next appropriate reconciliation opportunity, pending rows cause scheduling again. No user action must be reconstructed from a lost callback. This requires a real reconciliation design rather than assuming scheduling and database commit form one cross-system transaction.

2. Two devices edit the same private note from version 7. Define a conflict workflow that avoids silent loss.

Show worked solution

Accept the first write with a new server version and reject the second base-version mismatch as a conflict. Preserve both the pending local text and current server text, show a comparison or merge UI, then submit the resolved content against the current version using a new stable operation identity.

Key takeaways

  • Offline acceptance and remote confirmation are different states.
  • An outbox preserves intent across ambiguous failures.
  • Conflict and deletion policies determine consistency.

Official references