Offline-first mobile app background sync: conflicts, retries, UX
Plan offline-first mobile app background sync with clear conflict rules, retry logic, and a simple pending changes UX for native Kotlin and SwiftUI apps.

The problem: users edit offline and reality changes
Someone starts a task with a good connection, then walks into an elevator, a warehouse corner, or a subway tunnel. The app keeps running, so they keep working. They tap Save, add a note, change a status, maybe even create a new record. Everything looks fine because the screen updates right away.
Later, the connection comes back and the app tries to catch up in the background. Thatâs where background sync can surprise people.
If the app isnât careful, the same action can be sent twice (duplicates), or a newer change from the server can overwrite what the user just did (lost edits). Sometimes the app shows confusing states like âSavedâ and âNot savedâ at the same time, or a record appears, disappears, and reappears after sync.
A conflict is simple: two different changes were made to the same thing before the app had a chance to reconcile them. For example, a support agent changes a ticket priority to High while offline, but a teammate online closes the ticket. When the offline phone reconnects, both changes canât be applied cleanly without a rule.
The goal isnât to make offline feel perfect. The goal is to make it predictable:
- People can keep working without fear of losing work.
- Sync happens later without mystery duplicates.
- When something needs attention, the app clearly says what happened and what to do next.
This is true whether you hand-code in Kotlin/SwiftUI or build native apps with a no-code platform like AppMaster. The hard part isnât the UI widgets. Itâs deciding how the app behaves when the world changes while the user is offline.
A simple offline-first model (no jargon)
An offline-first app assumes the phone will lose the network sometimes, but the app should still feel usable. Screens should load and buttons should work even when the server isnât reachable.
Four terms cover most of it:
- Local cache: data stored on the device so the app can show something instantly.
- Sync queue: a list of actions the user took while offline (or while the network was flaky).
- Server truth: the version stored on the backend that everyone eventually shares.
- Conflict: when the userâs queued change no longer cleanly applies because the server version changed.
A useful mental model is to separate reads from writes.
Reads are usually straightforward: show the best available data (often from the local cache), then refresh quietly when the network returns.
Writes are different. Donât rely on âsaving the whole recordâ in one shot. That breaks as soon as youâre offline.
Instead, record what the user did as small entries in a change log. For example: âset status to Approved,â âadd comment X,â âchange quantity from 2 to 3.â Each entry goes into the sync queue with a timestamp and an ID. Background sync then tries to deliver it.
The user keeps working while changes move from pending to synced.
If youâre using a no-code platform such as AppMaster, you still want the same building blocks: cached reads for fast screens, and a clear queue of user actions that can be retried, merged, or flagged when a conflict happens.
Decide what really needs offline support
Offline-first can sound like âeverything works without a connection,â but that promise is where many apps get into trouble. Pick the parts that truly benefit from offline support, and keep the rest clearly online-only.
Think in terms of user intent: what do people need to do in a basement, on a plane, or in a warehouse with spotty service? A good default is to support the actions that create or update everyday work, and block actions where âlatest truthâ matters.
A practical set of offline-friendly actions often includes creating and editing core records (notes, tasks, inspections, tickets), drafting comments, and attaching photos (stored locally, uploaded later). Deleting can work too, but itâs safer as a soft delete with an undo window until the server confirms it.
Now decide what must stay real-time because the risk is too high. Payments, permission changes, approvals, and anything involving sensitive data usually should require a connection. If the user canât be sure the action is valid without checking the server, donât allow it offline. Show a clear âneeds connectionâ message, not a mysterious error.
Set expectations for freshness. âOfflineâ isnât binary. Define how stale data is allowed to be: minutes, hours, or ânext time the app opens.â Put that rule in the UI in plain words, like âLast updated 2 hours agoâ and âSyncing when online.â
Finally, flag high-conflict data early. Inventory counts, shared tasks, and team messages are common conflict magnets because multiple people edit them quickly. For those, consider limiting offline edits to drafts, or capturing changes as separate events instead of overwriting a single value.
If youâre building in AppMaster, this decision step helps you model data and business rules so the app can store safe drafts offline while keeping risky actions online-only.
Design the sync queue: what you store for every change
When a user works offline, donât try to âsync the database.â Sync the userâs actions. A clear action queue is the backbone of background sync, and it stays understandable when things go wrong.
Keep actions small and human, aligned with what the user actually did:
- Create a record
- Update specific field(s)
- Change status (submit, approve, archive)
- Delete (preferably soft delete until confirmed)
Small actions are easier to debug. If support needs to help a user, itâs much easier to read âChanged status Draft -> Submittedâ than to inspect a giant blob of changed JSON.
For every queued action, store enough metadata to replay it safely and detect conflicts:
- Record identifier (and a temporary local ID for brand-new records)
- Action timestamp and device identifier
- Expected version (or last-known updated time) of the record
- Payload (the specific fields changed, plus old value if you can)
- Idempotency key (a unique action ID so retries donât create duplicates)
That expected version is the key to honest conflict handling. If the server version has moved on, you can pause and ask for a decision instead of silently overwriting someone else.
Some actions must be applied together because the user sees them as one step. For example, âCreate orderâ plus âAdd three line itemsâ should succeed or fail as a unit. Store a group ID (or transaction ID) so the sync engine can send them together and either commit all of them or keep all of them pending.
Whether you build by hand or in AppMaster, the goal is the same: every change is recorded once, replayed safely, and explainable when something doesnât match.
Conflict resolution rules you can explain to users
Conflicts are normal. The goal isnât to make them impossible. The goal is to make them rare, safe, and easy to explain when they happen.
Name the moment a conflict happens: the app sends a change, and the server says, âThat record isnât the version you started editing.â This is why versioning matters.
Keep two values with every record:
- Server version (the current version on the server)
- Expected version (the version the phone thought it was editing)
If expected version matches, accept the update and bump the server version. If it doesnât, apply your conflict rule.
Pick a rule per data type (not one rule for everything)
Different data needs different rules. A status field isnât the same as a long note.
Rules users tend to understand:
- Last write wins: fine for low-risk fields like a view preference.
- Merge fields: best when fields are independent (status vs notes).
- Ask the user: best for high-risk edits like price, permissions, or totals.
- Server wins with a copy: keep the server value, but save the user edit as a draft they can re-apply.
In AppMaster, these rules map well to visual logic: check versions, compare fields, then choose the path.
Decide how deletes behave (or you will lose data)
Deletes are the tricky case. Use a tombstone (a âdeletedâ marker) instead of removing the record right away. Then decide what happens if someone edits a record that was deleted elsewhere.
A clear rule is: âDeletes win, but you can restore.â Example: a salesperson edits a customer note offline, while an admin deletes that customer. When sync runs, the app shows âCustomer was deleted. Restore to apply your note?â This avoids silent loss and keeps control with the user.
Retries and failure states: keep it predictable
When sync fails, most users donât care why. They care whether their work is safe and what will happen next. A predictable set of states prevents panic and support tickets.
Start with a small, visible status model and keep it consistent across screens:
- Queued: saved on the device, waiting for network
- Syncing: sending now
- Sent: confirmed by the server
- Failed: could not send, will retry or needs attention
- Needs review: sent, but the server rejected or flagged it
Retries should be gentle on battery and data. Use quick retries at first (to handle brief signal drops), then slow down. A simple backoff like 1 min, 5 min, 15 min, then hourly is easy to reason about. Also retry only when it makes sense (donât keep retrying a change that is invalid).
Treat errors differently, because the next action is different:
- Offline / no network: stay queued, retry when online
- Timeout / server unavailable: mark failed, auto-retry with backoff
- Auth expired: pause sync and ask the user to sign in again
- Validation failed (bad input): needs review, show what to fix
- Conflict (record changed): needs review, route to your conflict rules
Idempotency is what keeps retries safe. Every change should have a unique action ID (often a UUID) thatâs sent with the request. If the app re-sends the same change, the server should recognize the ID and return the same result instead of creating duplicates.
Example: a technician saves a completed job offline, then enters an elevator. The app sends the update, times out, and retries later. With an action ID, the second send is harmless. Without it, you might create duplicate âcompletedâ events.
In AppMaster, treat these states and rules as first-class fields and logic in your sync process, so your Kotlin and SwiftUI apps behave the same way everywhere.
Pending changes UX: what the user sees and can do
People should feel safe using the app offline. Good âpending changesâ UX is calm and predictable: it acknowledges that work is saved on the device, and it makes the next step obvious.
A subtle indicator works better than a warning banner. For example, show a small âSyncingâ icon in the header, or a quiet â3 pendingâ label on the screen where edits happen. Save scary colors for real danger (like âcanât upload because youâre signed outâ).
Give users one place to understand whatâs happening. A simple Outbox or Pending changes screen can list items with plain language like âComment added to Ticket 104â or âProfile photo updated.â That transparency prevents panic and reduces support tickets.
What users can do
Most people only need a few actions, and they should be consistent across the app:
- Retry now
- Edit again (creates a newer change)
- Discard local change
- Copy details (useful when reporting an issue)
Keep the status labels simple: Pending, Syncing, Failed. When something fails, explain it like a person would: âCouldnât upload. No internet.â or âRejected because this record was changed by someone else.â Avoid error codes.
Donât block the whole app
Only block actions that truly require being online, like âPay with Stripeâ or âInvite a new user.â Everything else should still work, including viewing recent data and creating new drafts.
A realistic flow: a field tech edits a job report in a basement. The app shows â1 pendingâ and lets them keep working. Later, it changes to âSyncing,â then clears automatically. If it fails, the job report stays available, marked âFailed,â with a single âRetry nowâ button.
If youâre building in AppMaster, model these states as part of each record (pending, failed, synced) so the UI can reflect them everywhere without special-case screens.
Auth, permissions, and safety while offline
Offline mode changes your security model. A user can take actions when they have no connection, but your server is still the source of truth. Treat every queued change as ârequested,â not âapproved.â
Login expiry while offline
Tokens expire. When that happens offline, let the user keep creating edits and store them as pending. Donât pretend actions that require server confirmation (like payments or admin approvals) are finished. Mark them as pending until the next successful auth refresh.
When the app is back online, attempt a silent refresh first. If you must ask the user to sign in again, do it once, then resume sync automatically.
After re-login, re-validate each queued item before sending it. The user identity may have changed (shared device), and old edits must not sync under the wrong account.
Permission changes and forbidden actions
Permissions can change while the user is offline. An edit that was allowed yesterday might be forbidden today. Handle this explicitly:
- Re-check permissions server-side for every queued action
- If forbidden, stop that item and show a clear reason
- Keep the userâs local edit so they can copy it or request access
- Avoid repeated retries for âforbiddenâ errors
Example: a support agent edits a customer note offline on a flight. Overnight, their role is removed. When sync runs, the server rejects the update. The app should show âCanât upload: you no longer have accessâ and keep the note as a local draft.
Sensitive data stored offline
Store the minimum needed to render screens and replay the queue. Encrypt offline storage, avoid caching secrets, and set clear rules for logout (for example: wipe local data, or keep drafts only after explicit user consent). If youâre building with AppMaster, start with its authentication module and design your queue so it always waits for a valid session before sending changes.
Common traps that cause lost work or duplicate records
Most offline bugs arenât fancy. They come from a few small decisions that feel harmless when you test with perfect Wi-Fi, then break real work later.
One common failure is silent overwrites. If the app uploads an older version and the server accepts it without checking, you can erase someone elseâs newer edit and nobody notices until itâs too late. Sync with a version number (or âlast updatedâ stamp) and refuse to overwrite when the server has moved on, so the user gets a clear choice.
Another trap is a retry storm. When a phone regains a weak connection, the app can hammer the backend every few seconds, draining battery and creating duplicate writes. Retries should be calm: slow down after each failure and add a little randomness so thousands of devices donât retry at the same moment.
The mistakes that most often lead to lost work or duplicates:
- Treating every failure like ânetworkâ: separate permanent errors (invalid data, missing permission) from temporary ones (timeout).
- Hiding sync failures: if people canât see what failed, they redo the task and create two records.
- Sending the same change twice without protection: always attach a unique request ID so the server can recognize and ignore duplicates.
- Auto-merging text fields without telling anyone: if you combine edits automatically, let users review the result when it matters.
- Creating records offline without a stable ID: use a temporary local ID and map it to the server ID after upload, so later edits donât create a second copy.
A quick example: a field tech creates a new âSite Visitâ offline, then edits it twice before reconnecting. If the create call is retried and creates two server records, the later edits may attach to the wrong one. Stable IDs and server-side deduping prevent this.
If youâre building this with AppMaster, the rules donât change. The difference is where you implement them: in your sync logic, your data model, and the screens that show âfailedâ vs âsentâ changes.
Example scenario: two people edit the same record
A field technician, Maya, is updating a âJob #1842â ticket in a basement with no signal. She changes the status from âIn progressâ to âCompletedâ and adds a note: âReplaced valve, tested OK.â The app saves instantly and shows it as pending.
Upstairs, her teammate Leo is online and edits the same job at the same time. He changes the scheduled time and assigns the job to a different technician, because a customer called with an update.
When Maya gets signal again, background sync starts quietly. Hereâs what happens in a predictable, user-friendly flow:
- Mayaâs change is still in the sync queue (job ID, fields changed, timestamp, and the record version she last saw).
- The app tries to upload. The server replies: âThis job was updated since your versionâ (a conflict).
- Your conflict rule runs: status and notes can be merged, but assignment changes win if they were made later on the server.
- The server accepts a merged result: status = âCompletedâ (from Maya), note added (from Maya), assigned technician = Leoâs choice (from Leo).
- The job reopens in Mayaâs app with a clear banner: âSynced with updates. Assignment changed while you were offline.â A small âReviewâ action shows what changed.
Now add one failure moment: Mayaâs login token expired while she was offline. The first sync attempt fails with âSign in required.â The app keeps her edits, marks them as âPaused,â and shows one simple prompt. After she signs in, sync resumes automatically without retyping anything.
If thereâs a validation problem (for example, âCompletedâ requires a photo), the app shouldnât guess. It marks the item as âNeeds attention,â tells her exactly what to add, then lets her resubmit.
Platforms like AppMaster can help here because you can design the queue, conflict rules, and pending-state UX visually, while still shipping real native Kotlin and SwiftUI apps.
Quick checklist and next steps
Treat offline sync like an end-to-end feature you can test, not a pile of fixes. The goal is simple: users never wonder whether their work is saved, and the app doesnât create surprise duplicates.
A short checklist to confirm the foundation is solid:
- The sync queue is stored on-device, and every change has a stable local ID plus a server ID when available.
- Clear statuses exist (queued, syncing, sent, failed, needs review) and are used consistently.
- Requests are idempotent (safe to retry), and each operation includes an idempotency key.
- Records have versioning (updatedAt, revision number, or ETag) so conflicts can be detected.
- Conflict rules are written in plain language (what wins, what merges, when you ask the user).
Once thatâs in place, verify the experience is just as strong as the data model. Users should be able to see whatâs pending, understand what failed, and take action without fear of losing work.
Test with scenarios that match real life:
- Airplane mode edits: create, update, delete, then reconnect.
- Flaky network: drop connection mid-sync and ensure retries donât duplicate.
- App killed: force close during sending, reopen, confirm the queue recovers.
- Clock skew: device time is wrong, confirm conflict detection still works.
- Duplicate taps: user hits Save twice, confirm it becomes one server change.
Prototype the full flow before polishing the UI. Build one screen, one record type, and one conflict case (two edits to the same field). Add a simple sync status area, a Retry button for failures, and one clear conflict screen. When that works, repeat for more screens.
If youâre building without coding, AppMaster (appmaster.io) can generate native Kotlin and SwiftUI apps alongside the backend, so you can focus on the queue, version checks, and user-facing states instead of wiring everything by hand.


