Conflict Detection and Resolution Patterns

Capítulo 9

Estimated reading time: 12 minutes

+ Exercise

Why conflicts happen in offline-first sync

In an offline-first app, multiple actors can change the same logical data without seeing each other’s latest state. A “conflict” is not just “two writes at the same time”; it is any situation where the system cannot safely and automatically decide what the correct merged result should be without a rule. Conflicts can occur between two devices of the same user, between different users collaborating on shared data, or between a device and a server-side process (automation, moderation, billing, background jobs).

Conflict handling has two parts: detection (recognizing that concurrent changes occurred) and resolution (deciding how to reconcile them). Good patterns make conflicts rare, predictable, and explainable to users. Bad patterns create silent data loss, confusing UI jumps, or endless “sync failed” loops.

Conflict taxonomy: what kind of conflict are you resolving?

Write-write conflicts (concurrent edits)

Two or more parties update the same field or record based on different prior states. Example: two devices edit the title of the same note while offline.

Write-delete conflicts

One party deletes an entity while another edits it. Example: a task is deleted on one device, but another device marks it completed offline.

Constraint conflicts

Changes violate a business rule or uniqueness constraint when combined. Example: two users create a project with the same “slug” that must be unique; each was valid locally but not globally.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

Ordering and list conflicts

Reordering items in a list concurrently can lead to ambiguous final order. Example: two users reorder a playlist offline.

Derived-data conflicts

Edits to base data and derived aggregates collide. Example: inventory count is derived from stock movements; a device edits a cached “count” field directly.

Authorization and policy conflicts

A change is valid locally but later rejected because permissions changed. Example: user loses access to a shared document while offline and continues editing.

Detection patterns: how to know a conflict occurred

Version-based detection (ETag / revision / row version)

The most common detection pattern is to attach a version token to each entity (or each field group). A client reads version V, edits, and submits an update “if version is still V”. If the server’s current version differs, the server rejects with a conflict response and provides the latest state.

Key design choice: entity-level versioning vs field-level versioning. Entity-level is simpler but can produce more conflicts (editing different fields still conflicts). Field-level reduces conflicts but increases metadata and complexity.

Causal metadata detection (vector clocks / dotted version vectors)

When you need to distinguish “concurrent” from “happened-after” across multiple replicas, causal metadata can help. Each replica maintains a counter; updates carry a vector of counters. If neither vector dominates the other, the updates are concurrent and a conflict exists. This is powerful for multi-master sync and peer-to-peer scenarios, but it increases payload size and requires careful pruning.

Operation semantic detection (preconditions on business invariants)

Instead of comparing versions, you detect conflicts by checking whether the intent can still be applied. For example: “reserve seat 12A if it is still available” or “apply discount if order is not yet paid”. If the invariant no longer holds, the operation conflicts. This pattern is often more user-meaningful than raw version mismatches.

Uniqueness and constraint detection

Conflicts can be detected by attempting to commit and receiving a constraint violation (unique index, foreign key, max capacity). The server should return a structured error that includes enough context for a deterministic client strategy (e.g., suggested alternative slug, or the conflicting record id).

Schema and migration mismatch detection

Sometimes “conflict” is actually “cannot interpret this change”. If a client is behind a schema version, it may send updates that are incompatible with current rules. Detect by including schema/app version in requests and returning a “requires upgrade” response rather than a generic conflict.

Resolution patterns: choosing a strategy per data type

Resolution is not one-size-fits-all. A robust system uses different strategies for different entities and even different fields. The goal is to preserve user intent and avoid silent loss.

Last-write-wins (LWW) with guardrails

LWW picks the update with the greatest timestamp (or server sequence). It is simple and can be acceptable for low-value, low-collaboration fields (e.g., “last opened at”). But it can silently discard meaningful edits. If you use LWW, add guardrails: keep previous values for audit, show “updated elsewhere” banners, and avoid using device clocks directly (prefer server-assigned ordering or hybrid logical clocks).

Field-wise merge

If two updates touch different fields, merge them automatically. This requires tracking which fields changed (a “patch” model) and applying non-overlapping changes. It works well for profile objects (name, avatar, preferences) and many forms.

CRDT-based resolution (data-structure-specific merging)

Conflict-free Replicated Data Types resolve concurrency by design. Examples: grow-only sets, observed-remove sets, counters, and sequence CRDTs for text. CRDTs are ideal when you need automatic merges without central coordination, but they add complexity and metadata. They shine for collaborative editing, reactions/likes, tags, and shared lists.

Operational transform / text merge

For rich text or long-form text, naive field-wise merge is not enough. You can use OT or a text CRDT to merge character-level edits. If you cannot support full collaborative editing, a pragmatic alternative is three-way merge with conflict markers and user choice.

Server-authoritative resolution with client rebase

In many business apps, the server is the source of truth for invariants. The server can accept or reject changes and return a canonical state. The client then rebases local edits on top of the canonical state (or asks the user). This is common for financial data, inventory, and workflows with strict transitions.

Manual resolution (user-in-the-loop)

When automatic merging risks incorrect outcomes, present a clear choice. Manual resolution is not a failure; it is a product feature. The key is to minimize how often it happens and to make it understandable: show “yours” vs “theirs”, highlight differences, and provide safe actions (keep mine, keep theirs, merge).

Step-by-step: implementing a practical conflict workflow

The following workflow assumes your system can detect conflicts (e.g., via version mismatch) and can return the latest server state. It focuses on what to do next without rehashing how you queue operations or perform optimistic UI.

Step 1: Classify the entity and field strategy

Create a per-entity (or per-field) conflict policy table. Example categories:

  • Auto-merge safe: preferences, flags, additive counters, tags.
  • Auto-merge with rules: list order, schedules, status transitions.
  • Manual: long text, legal fields, pricing, addresses.

This table drives both server behavior (how it responds) and client UX (whether to prompt).

Step 2: Capture enough context to rebase

When a user edits, store not only the new value but also the base version (or base snapshot hash) the edit was made against. For patch-based updates, store a patch plus base version. This enables three-way merge: base, local, remote.

Example data you want available at conflict time:

  • Entity id
  • Base version token (the version at edit time)
  • Local patch (what changed)
  • Optional: base snapshot of relevant fields (for three-way merge)

Step 3: On conflict response, fetch canonical remote state

When the server rejects with conflict, immediately obtain the latest remote state and its version token. Some APIs include it in the conflict response; otherwise fetch it. Avoid retry loops without new information.

Step 4: Attempt deterministic merge

Apply your policy:

  • Field-wise merge: if local patch touches fields that remote did not change since base, apply local patch onto remote.
  • Rule-based merge: apply domain rules (e.g., status transitions, max constraints).
  • CRDT merge: merge states/operations as defined by the CRDT.

If merge succeeds, produce a new patch against the latest remote version and submit it. If it fails or is ambiguous, proceed to manual resolution.

Step 5: Manual resolution UI with three-way context

Manual resolution should show:

  • Remote current: what others have now
  • Your local: what you wanted
  • Base: what you started from (optional but very helpful)

Provide actions that map to clear outcomes: keep remote, overwrite with local, or merge (edit a combined value). For text, show a diff view; for forms, show per-field pickers.

Step 6: Persist the resolution as a new change

Once resolved, treat the resolution as a new edit based on the latest remote version. This avoids “resolving” against stale data and reduces repeated conflicts.

Three-way merge for objects: a concrete example

Three-way merge uses a base snapshot B, local L, and remote R. For each field:

  • If L equals B, the user didn’t change it; take R.
  • If R equals B, only local changed; take L.
  • If L and R both differ from B and differ from each other, it’s a true conflict; require a rule or user choice.
// Pseudocode for per-field three-way merge (primitive fields only)  function merge3(base, local, remote):    result = {}    conflicts = []    for field in unionKeys(base, local, remote):      b = base[field]      l = local[field]      r = remote[field]      if deepEqual(l, b):        result[field] = r      else if deepEqual(r, b):        result[field] = l      else if deepEqual(l, r):        result[field] = l      else:        conflicts.push(field)        result[field] = r // placeholder; UI must decide    return { result, conflicts }

In practice, you rarely store full base objects for everything. A common compromise is to store base values only for fields the user edited, enabling a partial three-way merge.

Write-delete conflicts: patterns that avoid surprises

Tombstones with grace periods

If an entity is deleted remotely while a device edits it, you need a policy: resurrect, reject, or create a fork. Tombstones (a retained “deleted” marker) allow the system to detect that the entity existed and was deleted, rather than treating it as “not found”. A grace period can allow late edits to be applied as an undelete if that matches product expectations (e.g., recovering a note).

Resurrection vs fork

Resurrection means the edit undeletes the entity. This is user-friendly for personal notes but dangerous for compliance-driven deletes. Fork means create a new entity containing the local edits (e.g., “Task (recovered)”), preserving intent without violating deletion semantics. Decide per domain.

Hard deletes for sensitive data

For regulated or security-sensitive data, hard delete may be required. In that case, local edits must be rejected with a clear message and the UI should guide the user to copy any needed information elsewhere if allowed.

Constraint conflicts: turning server rejections into good UX

Unique fields (slugs, usernames)

When a unique constraint fails, the best resolution is usually to generate alternatives deterministically and present them. The server can return the conflicting value and suggestions; the client can offer “Use suggested” or “Edit”.

Capacity and quota conflicts

Example: two offline devices each add 8 items to a list with a max of 10. When syncing, one set must be rejected or partially accepted. A good pattern is partial commit with per-item results: accept the first N items by a stable ordering (server time or deterministic sort) and reject the rest with reasons. The client then marks rejected items and allows the user to retry after removing others or upgrading plan.

Workflow state machine conflicts

If entities have allowed transitions (e.g., Draft → Submitted → Approved), concurrent transitions can conflict. Prefer server-side validation and return the current state plus allowed next actions. The client can then offer a “Reapply” action if still valid (e.g., if it is still in Draft, resubmit; if already Approved, show that the action is no longer applicable).

List and ordering conflicts: stable, explainable ordering

Move operations vs absolute positions

Ordering conflicts are common when clients store “position = 3” style indices. A more resilient approach is to represent order with stable identifiers and relative moves (“place item X after item Y”). This reduces conflicts because operations compose better.

Deterministic tie-breaking

When two moves are concurrent, you need a deterministic tie-breaker to avoid oscillation across devices. Common tie-breakers include (a) server sequence number, (b) replica id + counter, or (c) lexicographic ordering of operation ids. Determinism matters more than “perfect fairness” because it prevents repeated reorders on each sync.

Designing conflict responses: API shapes that help clients

When the server detects a conflict, return structured data that enables the client to do something useful. A minimal but effective conflict response often includes:

  • Error code: e.g., CONFLICT_VERSION, CONFLICT_DELETED, CONFLICT_CONSTRAINT
  • Entity id
  • Current server version token
  • Current server representation (or a link to fetch it)
  • Conflict details: which fields, which constraint, allowed transitions
  • Suggested resolution hints: e.g., suggested slug, maximum allowed, next valid state
// Example JSON conflict payload (conceptual) {   "error": "CONFLICT_VERSION",   "entity": "note",   "id": "n_123",   "serverVersion": "v42",   "serverState": { "title": "Trip plan", "body": "..." },   "conflict": {     "fields": ["title"],     "message": "Title was changed on another device."   } }

Client UX patterns: making conflicts survivable

Inline, per-field resolution for forms

For structured forms, show conflicts at the field level rather than blocking the whole record. Example: highlight the “Phone number” field with a small panel: “Updated elsewhere: +1 555… Your change: +1 444…” with buttons to pick one. This reduces cognitive load and avoids forcing users to re-enter unrelated fields.

Non-blocking banners for auto-merged changes

If you auto-merge, still communicate. A subtle banner like “Updated with changes from another device” plus an undo/history link builds trust and helps users notice unexpected merges.

Conflict inbox / review queue

For apps where conflicts can accumulate (field work, inspections, sales), a dedicated “Needs review” section is effective. Each item shows what happened, why it needs attention, and offers a resolution flow. This prevents conflicts from blocking unrelated work.

Preserve user work even when rejected

If the server rejects an edit, never discard the user’s input. Keep it as a draft, a fork, or a pending change that can be copied. The worst UX is “sync failed” followed by missing edits.

Observability and testing: ensuring conflict logic is correct

Conflict metrics

Track conflict rate by entity type, field, and app version. High conflict rate often indicates overly coarse versioning, missing merge rules, or UI flows that encourage concurrent edits (e.g., shared checklists without presence indicators).

Determinism tests

For any automatic merge strategy, add tests that replay the same set of concurrent updates in different orders and assert the final state is identical. Non-deterministic merges cause “ping-pong” states across devices.

Fuzz and simulation

Simulate offline windows, random interleavings, and retries. Include scenarios like: edit-edit, edit-delete, create-create with same unique field, reorder-reorder, and permission changes mid-flight. The goal is to ensure every conflict path results in either a deterministic merge or a clear user action, never an infinite retry.

Now answer the exercise about the content:

In an offline-first app, which detection approach checks whether a user’s intended action can still be applied by validating business invariants rather than comparing version tokens?

You are right! Congratulations, now go to the next page

You missed! Try again.

Operation semantic detection treats a conflict as the intent no longer being valid (an invariant fails), such as reserving something only if it is still available, instead of relying on version mismatches.

Next chapter

CRDT Concepts for Multi-Writer, Eventually Consistent Data

Arrow Right Icon
Free Ebook cover Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms
47%

Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms

New course

19 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.