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.
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.
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.
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.