Conflict resolution
Last-write-wins vs. CRDT-light.
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
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
// 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 UpdateInputif err := c.ShouldBindJSON(&input); err != nil { /* 400 */ }var existing models.Bookmarkh.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.Noteh.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 UpdateNoteInputc.ShouldBindJSON(&input)var existing models.Noteh.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.Bodyexisting.Version++h.db.Save(&existing)c.JSON(200, gin.H{"data": existing})}
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.
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
Try it
Implement the LWW policy + audit log on bookmarks:
- Pick one model in your API (e.g.,
Note) and add aBodyfield if it doesn't have one. - In the handler, accept
If-Unmodified-Sincefrom the client. Apply the new value either way (LWW), but log conflicts. - On desktop: queue a note update via the outbox while offline.
- On mobile: edit the same note while offline.
- Reconnect both. Confirm whichever pushed last is what shows up, and that the audit log has ONE conflict entry.
- 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