Use a durable per-edit transactional outbox pattern, not a single sync flag. Each locally accepted edit should move through pending, sending, confirmed, blocked_for_repair, blocked_by_dependency, or discarded. Treat transport uncertainty differently from an explicit server rejection, preserve the user’s original input separately from fresh server state, and pause dependent edits until the user deliberately repairs or discards the rejected change. In an offline-first app, local persistence means saved on this device, not yet accepted by the server, and the local data source should drive reads while being persisted to disk (Build an offline-first app).
What should happen the moment the server rejects an edit?
Do not delete the queued change, and do not overwrite it with whatever the server currently says. Android’s guidance for building an offline-first app says the local data source is the canonical source of truth for app reads and is often backed by disk persistence, which is the right foundation for recovery after reconnect.
Move the edit into blocked_for_repair and attach three things to it: the original user input, the latest server snapshot, and a machine-readable outcome class. That class should at least distinguish:
transport_unknown: the request may or may not have been appliedconflict: the server explicitly rejected the change because the client edited an old versionvalidation_rejected: the server understood the request and refused it
That boundary matters. If delivery outcome is unknown, retry only with a stable operation identifier or other API-defined idempotent semantics. If the server explicitly rejected the request, stop automatic retries and ask for repair.
How do you separate retry from repair?
Use protocol results as signals, but classify them by your API contract, not by status-code folklore. RFC 9110 HTTP Semantics defines HTTP as a generic interface and describes protocol semantics rather than your business-rule meaning. So a 409 might mean stale-version conflict on one endpoint and something else on another. Likewise, a timeout is not a rejection; it is an uncertain delivery outcome.
Run the queue in persistent background work so it survives app restarts. Android’s WorkManager work request model is a good fit because a WorkRequest can carry constraints, retry configuration, and input data for durable execution. The state model and repair flow are platform-agnostic; WorkManager is just the Android example for durable background execution.
What about dependent queued edits?
Model dependencies explicitly. If edit B depends on edit A, and A is rejected, B should become blocked_by_dependency rather than repeatedly failing. This is the ordering boundary that matters: a child mutation must not be released until the parent mutation is confirmed or explicitly replaced by a user-approved repair path.
Worked example: an offline field inspector creates a draft repair note, then adds two photo attachments. After reconnect, the server rejects the draft update because the work order was already closed by another technician. Keep the note text locally, fetch the current work order, and block the photo uploads behind the rejected parent. The user should see: “Your note is still on this device. The work order changed on the server. Review and resubmit or discard.” Do not silently create a new note on a different record just because attachments are waiting.
What implementation checklist prevents data loss?
- Persist every mutation with local ID, target record, dependency links, payload, and state.
- Store
original_inputseparately fromserver_snapshot. - Record whether the outcome is unknown, conflicted, or permanently rejected.
- Retry only unknown outcomes, using API-supported idempotency.
- Block descendants when an ancestor is rejected.
- Make repair or discard an explicit user decision.
- Test restart during upload, lost acknowledgement, duplicate delivery, and rejected parent edits.
Q: Should the app auto-merge conflicts?
Only for narrowly defined fields where the merge rule is guaranteed safe and understandable.
Q: Should fresh server data replace the rejected local edit in the UI?
No. Show both until the user resubmits or discards the local change.
Next step: define your outbox schema with explicit state, dependency, original_input, server_snapshot, and outcome-class fields before you change the sync UI.
Reviewed: 2026-09-06
Sources are linked throughout this guide. Product capabilities can change; consult the linked documentation for your deployment.
Read our editorial approach ↗