Conflict resolution

Last-write-wins vs. CRDT-light.

8 minhard

Mobile edits a note offline. Desktop edits the same note offline. Both reconnect. Whose change wins? Without a policy, you get data loss or silent corruption. This lesson is the policy.

The shape of the conflict

The two-actor conflict

time ────────────────────────────────────────────► Mobile: read note (v1) ── offline edit ──► push (v2-mobile) │ ▼ Server: v1 ─────────────────────────────────── ? ─────────► ▲ │ Desktop: read note (v1) ── offline edit ──► push (v2-desktop)
Both clients changed the same row while disconnected. Reconciliation needs a deterministic rule.

Three policies, ranked by complexity

1. Last-write-wins (LWW)

Simplest, cheapest, lossy. Whichever push arrives second clobbers the first. Defensible when:

  • The same user owns both devices (low collision).
  • The data has low value (preferences, bookmarks).
  • You can't reasonably merge two states.

Default for Grit. The bookmark feature uses LWW because: it's a personal toggle, conflicts are rare, and the user wouldn't notice if the wrong "last edit" wins.

2. Server-wins / Client-wins

A constant rule: the server's version always wins (the client's pending changes are dropped) OR the client always wins (the server takes whatever the client sends).

  • Server-wins: safe for read-mostly data (catalogs, settings managed in admin).
  • Client-wins: simple for user-owned data (notes, drafts) but loses if two clients race.

3. Version + 409 + manual merge

Each row has a version column. The client sends the version it's editing. The server compares; if newer than expected, returns 409 with the latest. The client merges (or asks the user).

Highest fidelity, highest cost. Use it for high-stakes data — invoice totals, contract text, anything where silent data loss is unacceptable.

Implementing LWW with timestamps

apps/api/internal/handlers/bookmark.go
// Client sends If-Unmodified-Since with the timestamp it read.
// Server compares and accepts only if the row hasn't changed.
func (h *BookmarkHandler) Update(c *gin.Context) {
var input UpdateInput
if err := c.ShouldBindJSON(&input); err != nil { /* 400 */ }
var existing models.Bookmark
h.db.First(&existing, c.Param("id"))
clientTs, _ := time.Parse(time.RFC3339, c.GetHeader("If-Unmodified-Since"))
if existing.UpdatedAt.After(clientTs) {
// The server has a newer version. Accept the new write anyway (LWW)
// BUT log it so we can see how often it happens.
auditConflict(existing, input)
}
existing.Note = input.Note
h.db.Save(&existing)
c.JSON(200, gin.H{"data": existing})
}

Pure LWW: the most recent write wins. The conflict is logged but doesn't change the outcome. Use this until your audit log shows conflicts often enough to need 409.

Implementing 409 + manual merge

func (h *NoteHandler) Update(c *gin.Context) {
var input UpdateNoteInput
c.ShouldBindJSON(&input)
var existing models.Note
h.db.First(&existing, c.Param("id"))
if existing.Version != input.BaseVersion {
c.JSON(409, gin.H{
"error": "version_conflict",
"server_version": existing,
})
return
}
existing.Body = input.Body
existing.Version++
h.db.Save(&existing)
c.JSON(200, gin.H{"data": existing})
}
apps/desktop/frontend (snippet)
async function saveNote(note: Note, body: string) {
const res = await fetch(`/api/notes/${note.id}`, {
method: 'PUT',
body: JSON.stringify({ body, base_version: note.version }),
})
if (res.status === 409) {
const { server_version } = await res.json()
return showMergeUI(local: body, server: server_version.body)
}
}

showMergeUI is up to you — a side-by-side diff, a "keep mine / keep theirs" toggle, or an auto-merge for non-overlapping fields.

Per-field policies

Real apps mix policies per field within the same record:

  • name — LWW. Cheap, low stakes.
  • price_cents — 409. High stakes, requires care.
  • updated_at — server-owned. Client cannot edit.
  • tags — merge (set-union). No data loss.

That last one is poor man's CRDT: any field that's a set, list, or counter can usually be merged commutatively (union, max, sum). Use this where you can.

Conflicts you can't resolve are bugs. If the user can't tell the system how to reconcile, neither can your code. Don't pretend by silently picking one. Log it, surface it ("Two versions of this note — pick one"), or prevent it (lock-on-edit).

Lock-on-edit — the cheap escape hatch

If conflicts are rare AND high-stakes, lock the record while someone's editing. Google Docs solved this differently (real-time collab), but smaller apps can use a stale lock:

// When user opens for edit:
POST /api/notes/123/lock → 200 if lock acquired, 409 if locked by someone else
// Lock auto-releases after 5 minutes of inactivity.

Pessimistic, but for low-frequency edits across desktop + web + mobile (e.g., a shared invoice), it's the simplest thing that works.

The audit log

Regardless of policy, log every conflict. The Grit API has an audit_logs table — use it:

auditLog.Create(&AuditLog{
Entity: "bookmark", EntityID: existing.ID,
Action: "conflict_lww", UserID: userID,
Meta: jsonOf(map[string]any{
"server_ts": existing.UpdatedAt, "client_ts": clientTs,
"client_origin": c.GetHeader("X-Client"),
}),
})

Now you can answer "how often do conflicts happen?" with data. If the answer is "5 per month", LWW is fine. If it's 500/day, time for 409 + merge.

Quick check

Your app shows the user's notes across web, mobile, desktop. Mobile + desktop both edit the same note offline. Which conflict policy is the best STARTING point?

Try it

Implement the LWW policy + audit log on bookmarks:

  1. Pick one model in your API (e.g., Note) and add a Body field if it doesn't have one.
  2. In the handler, accept If-Unmodified-Since from the client. Apply the new value either way (LWW), but log conflicts.
  3. On desktop: queue a note update via the outbox while offline.
  4. On mobile: edit the same note while offline.
  5. Reconnect both. Confirm whichever pushed last is what shows up, and that the audit log has ONE conflict entry.
  6. Bonus: render conflict count on an admin dashboard widget.

What's next

Chapter 5 — Coordinated releases. Now that all four surfaces talk to one API and survive offline, we face the last problem: shipping changes without breaking old clients still in the wild.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub