Conflict resolution strategies
What to do when two people change the same data: from last write wins per field, to version checks with If-Match, to three-way merges and merge screens, chosen separately for each kind of data.
The problem: two edits, one record
A conflict happens when two changes to the same data are made without either one knowing about the other. You don't need a real-time editor to get one. Two support agents editing the same ticket, one person with the app open in two tabs, or a phone that syncs after a day offline will all produce conflicts.
The worst outcome isn't a conflict. It's a conflict nobody notices. One person's work is silently overwritten, and they find out a week later when a customer complains. Any design that lets more than one client write the same data should decide, on purpose, what happens when changes collide.
The lost update, step by step
Here is the classic case, called the lost update. Asha and Ben both work in a shop's admin tool. A product record holds price: 10 and stock: 50.
- Asha opens the product. Her form loads
price: 10, stock: 50. - Ben opens the same product a few seconds later. His form loads the same values.
- Asha changes the price to 12 and clicks Save. The client sends the whole form with
PUT /products/7:price: 12, stock: 50. - Ben counts the shelf, changes the stock to 40, and clicks Save. His form still holds the old price, so it sends
price: 10, stock: 40. - The server stores whatever arrived last. The product now says
price: 10, stock: 40. Asha's price change is gone, and nobody saw an error.
PUT sent the whole form back, including the old price, and the server stored the last write.Nothing crashed and every request returned 200 OK. That is what makes the lost update dangerous: the system looks healthy while it throws work away. Asha will reload tomorrow, see the old price, and assume she forgot to save.
Where conflicts come from
Every conflict has the same shape: a client reads a copy, edits the copy for a while, and writes it back. The longer the gap between the read and the write, the more likely someone else wrote in between. Some common sources:
- Long-lived forms. An edit page left open over lunch holds a copy that is an hour old.
- Several tabs or devices. The same person is the other writer. This is a common source in personal apps such as notes and to-do lists.
- Offline edits. A field technician edits a work order with no signal, and syncs four hours later. See the offline-first lesson for the sync queue itself.
- Optimistic updates. The UI shows a change before the server confirms it, so a rejected or reordered write must be undone or merged. The optimistic updates lesson covers the rollback side.
- Background jobs. A nightly import or a webhook writes the same record a person is editing.
There is no single right strategy
You'll meet five tools in this lesson: last write wins, version checks, three-way merge, merge screens, and data types that merge by design. Good products mix them. A design tool lets the newest color win, refuses a stale price change, and merges two people's tags. The skill is choosing per kind of data, and being able to say what each choice can lose.
Think first
Your notes app saves the whole note with PUT every 2 seconds while the user types. The user has the same note open in two tabs and types in one of them. What happens to the other tab?
The idle tab still holds the old copy. If it autosaves too (for example on blur or a timer), it sends the old text and wipes out what was just typed in the other tab.
This is a lost update with a single user. The fixes are the subject of this lesson: send only what changed, check a version before saving, and keep the idle tab in sync (the cross-tab sync lesson shows BroadcastChannel for that).
The mental model: three questions
Every conflict strategy is an answer to three questions. Keep them in your head and any design becomes easier to reason about, from a settings page to a multiplayer canvas.
- What is the unit of conflict? The whole record, one field, one item in a list, or one character. Two edits only conflict if they touch the same unit.
- Who decides the order? The client's clock, the server's arrival order, or a version number. This decides what "last" means.
- What did both sides start from? The common ancestor, often called the base: the version both editors loaded before they changed anything. Without it you can see that two copies differ, but not who changed what.
Shrink the unit, shrink the conflicts
Go back to Asha and Ben. If the unit is the whole product, their edits collide, because both wrote the product. If the unit is a field, they don't: Asha touched price, Ben touched stock, and both changes can be kept.
Nothing about the people changed. Only the size of the unit did.
This is the single most useful idea in the lesson. Most real conflicts in business apps are false conflicts: two people changed different parts of the same record. Making the unit smaller removes them for free. True conflicts, where two people changed the same field to different values, are much rarer, and those are the ones worth a careful strategy.
An everyday comparison
Think of a shared recipe card on the fridge. Your partner adds "2 eggs" to the shopping list while you cross out "milk". Both changes stay; there's no conflict.
If you both change the oven temperature, one to 180 and the other to 200, someone has to choose. A sensible household doesn't throw away the whole card when that happens. It keeps every change it can and asks only about the oven.
The strategies on one page
| Strategy | What happens on a true conflict | What can be lost | Good for |
|---|---|---|---|
| Last write wins (LWW) | The later write replaces the earlier one | The earlier value, silently | Statuses, titles, colors, positions |
| Version check (optimistic concurrency) | The later write is refused with 412 or 409 | Nothing, but the user must redo or merge | Prices, stock, contracts, anything costly |
| Three-way merge | Non-overlapping changes merge; overlaps are flagged | Nothing, if overlaps go to a person | Forms, config files, documents with structure |
| Merge screen | A person sees both versions and decides | Nothing, but it costs the user time | Rare, valuable conflicts |
| Mergeable data types | Both changes are combined by rule | Nothing for the rule's intent | Counters, sets, text, lists |
Notice the third column. Every strategy loses something or costs something.
Last write wins is the cheapest for the user and the most likely to lose data. A merge screen loses nothing and is the most expensive for the user. Your job is to put each field where its cost of loss matches the cost of asking.
Check your understanding · Exercise 1
Which of these are false conflicts, where the edits don't really overlap and a small enough unit of conflict would keep both? Select all that apply.
Last write wins, per record and per field
Last write wins (LWW) is the rule you get when you do nothing: the server stores each write as it arrives, so the latest one replaces whatever was there. It has a bad name because of the lost update, but the rule itself is often right. For a status, a title, or a color, the newest value really is what people want. The problem in the lost update was the size of the write, not the rule.
Per record: the default trap
With LWW per record, the whole object is the unit. The client sends every field with PUT, and the server replaces the stored object. Any two overlapping edit sessions conflict, even when they touched different fields, and the earlier one loses everything.
This is fine for objects one person owns and edits in one place, such as a user's own profile on one device. It is wrong as soon as two people or two tabs can edit the same thing.
Per field: send only what changed
With LWW per field, each field is its own unit. The client tracks which fields the user actually touched and sends only those with PATCH. Ben's save becomes PATCH /products/7 with the body { "stock": 40 }, and Asha's price survives. A common body format is JSON Merge Patch (RFC 7396): fields present are set, a field set to null is removed, and missing fields are left alone.
// Track the fields the user changed, not the whole form.
function dirtyFields<T extends object>(loaded: T, current: T): Partial<T> {
const patch: Partial<T> = {};
for (const key of Object.keys(current) as (keyof T)[]) {
if (!Object.is(loaded[key], current[key])) patch[key] = current[key];
}
return patch;
}
const patch = dirtyFields(loadedProduct, formValues); // { stock: 40 }
await fetch("/api/products/7", {
method: "PATCH",
headers: { "Content-Type": "application/merge-patch+json" },
body: JSON.stringify(patch),
});
Per-field LWW needs nothing new on the server beyond a PATCH handler that updates only the given columns. It removes almost every false conflict in a typical admin tool. It is the first fix to reach for, and often the only one a settings page needs.
stock lands without touching anything else; only Asha's price, which Ben also wrote, is lost.How Figma uses it: per property, server ordered
Figma is the best-known product built on per-field LWW. Its engineering team described the design in a 2019 post on its multiplayer system, as inspired by CRDTs but simplified because a central server is the authority. A Figma file is a tree of objects, and each object is a set of properties such as x, width, fill, and parent. The server keeps the latest value it has received for each property of each object.
- Two people changing different properties of the same object never conflict. One moves a rectangle, the other recolors it, and both changes stay.
- Two people changing the same property of the same object conflict, and the value the server received last wins. On a canvas, that is almost always what the users expect.
- The server is the single authority on order. Clients don't compare timestamps; they apply whatever the server broadcasts.
- A client that has sent a change the server hasn't confirmed yet ignores incoming server values for that property. Otherwise the user would see their own edit flicker back to the old value and then forward again.
The team chose this over operational transformation, which it found more complex than its problem needed: Figma isn't a text editor, and a design file is mostly independent properties, not one long string. Long text that many people edit at once is a different story, which is why the right strategy depends on the data, not on the product.
What LWW still loses
Even per field, LWW silently drops the earlier of two writes to the same field. That is fine for a color. It is wrong for these cases:
- Counters. Two clients read
likes: 4, both writelikes: 5. One like is gone. The fix is to send the operation (+1), not the result. - Lists and sets stored as one field. Asha adds tag
sale, Ben adds tagnew. Each writes the full array, and one tag is lost. Sendaddandremoveoperations instead. - Long text in one field. A description is one unit, so one person's paragraph replaces another's edit in a different paragraph.
- Values with business rules. A price or a refund amount where a lost change costs money. Use a version check.
Check your understanding · Exercise 2
A to-do app uses per-field LWW with PATCH. Which edits made at the same time can still lose data? Select all that apply.
Who is last? Clocks and ordering
Last write wins needs a clear meaning of "last". Online, it's easy: the server stores writes in the order they arrive. Offline it's harder. A phone that edits a field at 09:00 and syncs at 13:00 has to say when its change happened, and the obvious answer, a timestamp from the phone's clock, is the one that breaks.
Why client clocks can't be trusted
Device clocks drift and get set wrong. Phones usually sync with network time, but laptops can be minutes off, users change the time to cheat in games, and a device with a dead clock battery can boot into a year long past. If the merge rule is "higher timestamp wins", a client whose clock runs 10 minutes fast wins every conflict for 10 minutes, even against edits made after it.
The figure shows the failure. Ben's clock is 5 minutes fast. He edits at real time 10:00, stamped 10:05. Asha edits later, at real time 10:02, stamped 10:02.
Sorting by timestamp, Ben wins, and Asha's newer change is thrown away. Neither person did anything wrong.
Better ways to order writes
- Server arrival order. The server assigns the order as writes arrive, for example with an increasing version number per record. Simple and correct for online apps. For offline apps it means "last to sync wins", which may not be what the user expects, but it is at least consistent and can't be gamed by a clock.
- Server timestamps. The server stamps each write with its own clock. Fine when there is one database or the servers keep their clocks closely in sync. Still "last to arrive", not "last to be made".
- Version numbers. Each record carries a counter that goes up on every write. A write says which version it was based on. This doesn't pick a winner by itself; it detects that a conflict happened, which is the basis of the next chapter.
- Logical clocks. A Lamport clock is a counter each replica keeps: increment it on every local change, and on receiving a change, jump to one more than the larger of the two values. It orders events by cause, not by wall time. The CRDTs lesson uses these for ordering inside data types.
- Hybrid logical clocks (HLC). A timestamp made of the wall-clock time plus a small counter. It stays close to real time, so it's readable in logs, but it never goes backwards and always orders a change after anything its device has already seen. Several distributed databases use HLCs; CockroachDB, for example, uses them to order transactions.
A hybrid logical clock in a few lines
You won't need to write one in most apps, and a production version also guards against a wall clock that jumps far ahead. The sketch below shows the core idea so you can explain it: take the larger of your wall time and the last time you've seen, and use a counter to break ties within the same millisecond.
type Hlc = { wall: number; count: number; node: string };
let last: Hlc = { wall: 0, count: 0, node: "phone-a" };
function tick(now = Date.now()): Hlc { // a local change
const wall = Math.max(now, last.wall);
const count = wall === last.wall ? last.count + 1 : 0;
return (last = { ...last, wall, count });
}
function receive(remote: Hlc, now = Date.now()): Hlc { // a change from elsewhere
const wall = Math.max(now, last.wall, remote.wall);
let count = 0;
if (wall === last.wall && wall === remote.wall) count = Math.max(last.count, remote.count) + 1;
else if (wall === last.wall) count = last.count + 1;
else if (wall === remote.wall) count = remote.count + 1;
return (last = { ...last, wall, count });
}
const compare = (a: Hlc, b: Hlc) =>
a.wall - b.wall || a.count - b.count || a.node.localeCompare(b.node);
An HLC fixes causality: if Ben's phone has already received Asha's change, Ben's next change is ordered after it, whatever his clock says. It does not make two truly concurrent offline edits fair. One of them still wins by a rule, and the other is lost.
Clocks pick a winner; they don't decide whether losing was acceptable. That is what the next chapters are for.
Check your understanding · Exercise 3
An offline notes app resolves conflicts per field with "the higher updatedAt from the device wins". Which problems can this cause? Select all that apply.
Version numbers and optimistic concurrency
When a lost change would hurt, don't pick a winner. Detect the conflict and refuse the second write. Every record gets a version, and every write says which version it was based on. If the record has changed since, the server says no and changes nothing.
This is optimistic concurrency control: optimistic because nobody locks the record while editing; we assume conflicts are rare and check only at save time.
The other option, pessimistic locking, marks a record as "being edited by Asha" and blocks Ben until she's done. It works for checkouts of big documents, but locks get stuck when a tab closes or a laptop sleeps, so they need expiry times and a "take over" button. For most web apps, optimistic checks are simpler and fail more gracefully.
In HTTP: ETag and If-Match
HTTP has this built in, as the REST API design lesson showed. The server sends the version as an ETag header with the record. The client keeps it and sends it back in If-Match when it saves. If the record's current ETag is different, the server answers 412 Precondition Failed and changes nothing.
GET /api/products/7
200 OK
ETag: "v41"
{ "price": 10, "stock": 50 }
PATCH /api/products/7 (Asha, based on v41)
If-Match: "v41"
{ "price": 12 }
200 OK
ETag: "v42"
PATCH /api/products/7 (Ben, also based on v41)
If-Match: "v41"
{ "stock": 40 }
412 Precondition Failed
412 is not the end of the save, it's a detour. Most refusals merge cleanly and loop back to Saving without the user noticing; only true overlaps reach a person.On the server: one conditional update
The check must be atomic: reading the version and then writing in two steps leaves a gap where another write can slip in. In SQL it's a single statement that only matches the row if the version is still the one the client saw. If zero rows changed, the version was stale.
UPDATE products
SET stock = 40, version = version + 1
WHERE id = 7 AND version = 41;
-- 1 row updated: success, new version is 42
-- 0 rows updated: someone wrote first, answer 412
412, 409, and 428
| Status | Means | When to use it |
|---|---|---|
412 Precondition Failed | A condition in the request headers (If-Match) is false | The version check failed. The standard answer for If-Match. |
409 Conflict | The request clashes with the current state of the resource | Many APIs send the version in the body ("baseVersion": 41) and answer 409 with the current record. Also for clashes that aren't about versions, like a taken username. |
428 Precondition Required | This write must be conditional | The server refuses writes that don't send If-Match at all, so no client can skip the check (RFC 6585). |
Whichever code you choose, return the current record (or tell the client to fetch it) in the error. The client can't help the user without seeing what changed.
What the client does after a refusal
A version check only detects the conflict. The client still has to resolve it, and there are three reasonable moves, cheapest first:
- Rebase quietly. Fetch the latest record and its ETag. If the user's changed fields weren't changed by the other writer, re-apply the user's patch on top and save again with the new ETag. This is a three-way merge, covered next. The user never sees a conflict.
- Ask. If the same field was changed on both sides, show a merge screen with both values and let the user choose.
- Discard with notice. For low-value edits, keep the server's version and tell the user what was dropped, for example "Ben changed this ticket while you were editing. Your change to Priority was not saved."
Details that bite
- Strong ETags only.
If-Matchuses strong comparison, so a weak ETag (W/"v41") never matches. Generate a strong ETag from the version number or a hash of the stored data. - CORS. Browsers only let JavaScript read the
ETagresponse header on cross-origin requests if the server lists it inAccess-Control-Expose-Headers. A missing ETag on the client is a common reason version checks silently do nothing. - Every write path. Bulk edits, imports, and admin scripts must bump the version too. A job that writes the row without changing the version makes every open editor's check pass when it shouldn't.
- Lists. A version on the list ("the board") makes every card edit conflict with every other. Put versions on the smallest unit that people edit independently, usually each item.
Check your understanding · Exercise 4
A client sends PATCH /tickets/9 with If-Match: "v7". The ticket is now at "v9". Which server behaviors are correct? Select all that apply.
Three-way merge with a common ancestor
A version check tells you a conflict happened. A three-way merge tells you what kind. It compares three copies of the record: the base both editors started from, mine (the local edit), and theirs (what the server has now). With all three, the merge can keep every change that doesn't overlap and flag only the ones that do.
Why two copies are not enough
Suppose you only have mine and theirs. Mine says stock: 50, theirs says stock: 40. Did I leave stock alone while they changed it, or did they leave it alone while I changed it?
From two copies you can't tell; you only see that they differ. A two-way comparison has to treat every difference as a conflict, or pick a side blindly.
Add the base, stock: 50, and the answer is obvious: mine equals the base, so I didn't touch it; theirs changed it; take theirs. The base turns "these differ" into "who changed what". That's why Git finds the merge base, the last commit both branches share, before merging, and why your client should keep the version it loaded instead of throwing it away.
The rules, per field
| Base vs mine | Base vs theirs | Result |
|---|---|---|
| same | same | Nobody changed it: keep it |
| same | changed | Only they changed it: take theirs |
| changed | same | Only I changed it: take mine |
| changed | changed, same value as mine | We agree: take it, no conflict |
| changed | changed, different value | True conflict: apply a rule or ask a person |
A field-level three-way merge
Here is the whole algorithm for flat records. A missing key counts as deleted, so a delete on one side and no change on the other removes the field. On a true conflict this version keeps theirs and reports the field, so the UI can ask the user. It's tested in Node with the price and stock example, an agreed change, deletes, and a delete-versus-edit clash.
type Rec = Record<string, unknown>;
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
export function threeWayMerge(base: Rec, mine: Rec, theirs: Rec) {
const merged: Rec = {};
const conflicts: string[] = [];
const keys = new Set([...Object.keys(base), ...Object.keys(mine), ...Object.keys(theirs)]);
for (const key of keys) {
const b = base[key], m = mine[key], t = theirs[key];
const iChanged = !same(m, b);
const theyChanged = !same(t, b);
let value = t; // nobody changed it, or only they did
if (iChanged && (!theyChanged || same(m, t))) value = m; // only I did, or we agree
else if (iChanged && theyChanged) conflicts.push(key); // both, differently: flag it
if (value !== undefined) merged[key] = value; // a missing key means deleted
}
return { merged, conflicts };
}
// base { price: 10, stock: 50 }, mine { price: 12, stock: 50 }, theirs { price: 10, stock: 40 }
// => { merged: { price: 12, stock: 40 }, conflicts: [] }
JSON.stringify equality is fine for a sketch; it is sensitive to key order inside nested objects, so real code uses a deep-equal helper.Wire it into the save flow from the last chapter. On 412, fetch theirs with its new ETag. Run the merge with the base you kept.
If conflicts is empty, send the merged changes with the new If-Match and the user never knows. If not, show a merge screen for just those fields, with everything else already merged.
Beyond flat records
- Nested objects. Recurse: merge an address object field by field rather than as one value, so a changed
cityand a changedzipboth survive. - Sets (tags, members). Compute what each side added and removed relative to the base, then apply both:
result = base + added(mine) + added(theirs) - removed(mine) - removed(theirs). Adding the same tag twice is not a conflict. - Ordered lists. Harder. Two people reordering the same list rarely merge cleanly. Give items stable ids and store order as a sortable key per item, so moving one card is a write to one field.
- Text. A line-based three-way merge, like Git's, merges edits to different lines and flags edits to the same line. For prose edited live by many people, use OT or a CRDT instead.
Check your understanding · Exercise 5
Base: { title: "Q3 plan", owner: "asha", due: "09-30" }. Mine: { title: "Q3 roadmap", owner: "asha", due: "10-15" }. Theirs: { title: "Q3 plan", owner: "ben", due: "10-01" }. What does the field-level three-way merge produce? Select all that apply.
Merge UIs: show both, pick, or merge
When a true conflict reaches a person, the screen you show decides whether they resolve it in five seconds or give up and lose work. A good merge UI answers three questions at a glance: what did I change, what did they change, and what will be saved if I click this button.
Three shapes of merge UI
- Pick a side. Two columns, "Your version" and "Current version", with a button under each. Fast and clear for one or two fields. Weak when the user wants parts of both.
- Pick per field. Only the conflicting fields are listed, each with both values and a choice. Fields that merged cleanly are shown as already done. This is the best default for forms.
- Edit the result. A third, editable box starts with one version, and the user combines them by hand. Needed for text, where the right answer is often a mix of both.
Two famous designs: Git markers and Google Docs suggestions
Git writes both versions into the file between conflict markers and stops the merge until a person edits the file, removes the markers, and marks it resolved. The base can be shown too, with the merge.conflictStyle setting diff3 (or zdiff3, added in Git 2.35).
<<<<<<< ours
Due date: 15 October
||||||| base
Due date: 30 September
=======
Due date: 1 October
>>>>>>> theirs
This works for developers because they expect conflicts, have tools to resolve them, and nothing else can happen until they do. It would be a terrible UI for a shop admin. Never show raw markers or JSON diffs to end users; translate the conflict into fields and words they know.
Google Docs mostly avoids the question. Live editing merges text automatically, character by character.
When an edit needs approval, the author uses suggestion mode: the change appears inline, with deleted text struck through, new text colored, and the author's name beside it. Someone with edit access then accepts or rejects each one. A conflict becomes a proposal that sits in place until someone decides. The same pattern works for any workflow where changes need review, such as pricing changes that need a manager.
| Question | Git conflict markers | Google Docs suggestions |
|---|---|---|
| When the person decides | After the clash, before anything else can continue | Whenever the owner reviews, the document stays usable |
| What they see | Both versions as raw text, optionally with the base | The change inline, with author and a comment thread |
| Unit | Lines | Any span of text |
| Who it suits | Developers with merge tools | Writers, reviewers, approval flows |
What a good merge screen does
- Keep the user's work first. Never clear the form on a conflict. The user's draft is the most valuable thing on the screen.
- Say who and when. "Ben changed this 2 minutes ago" turns an abstract error into something the user can act on, or ask Ben about.
- Show only what conflicts. Merge everything else automatically and say so ("3 other changes were combined").
- Make the default safe. Pre-select the user's own value for fields only they edited and require a choice for true conflicts. Don't pre-select "discard my changes".
- Re-check on save. The merge result is a new write based on the version just fetched. It needs the new ETag, and it can conflict again if someone saved meanwhile.
Think first
Your product has thousands of small conflicts a day on ticket fields like priority and assignee. Should every one get a merge screen?
No. A merge screen costs the user time and attention; spend it only where a wrong guess is expensive. For low-value fields, merge per field automatically, let the latest value win, and show a quiet notice such as "Ben also changed Priority; his value was kept" with an undo.
Reserve the blocking merge screen for fields where losing a change costs money or trust, like a refund amount or a contract clause. Measure how often it appears; if users see it daily, the unit of conflict is probably too large.
Choosing a strategy per data type
Real records mix kinds of data. A product has a name (text), a price (money), a stock count (a counter), tags (a set), and an active flag (a boolean). One strategy for the whole record is always wrong for some field. The mature approach, the one Figma made famous, is to choose a merge rule for each field from its type and from what a lost change would cost.
A rule for each kind of data
| Data | Good default | Why | What to avoid |
|---|---|---|---|
| Counters (likes, stock moves, votes) | Send operations (+1, -3) and add them up | Two increments both count; order doesn't matter | Sending the new total, which loses one side |
| Sets (tags, members, labels) | Add-wins set: merge adds and removes per item | Two people adding different tags both keep theirs | Writing the whole array as one LWW value |
| Booleans and enums (done, status) | LWW per field, server order | The newest choice is usually the intended one | Merge screens for every flip; it's noise |
| Short text (titles, names) | LWW per field, maybe a notice | Rarely edited at once; easy to redo | Character merging, which can produce odd mixes like "Q3 roadmapplan" |
| Long text (descriptions, docs) | Text CRDT or OT if live; line merge plus review if not | Edits in different places should both survive | One LWW string for the whole body |
| Money and legal values (price, refund, terms) | Version check, then a person decides | A silent wrong value costs money or trust | Any automatic winner |
| Positions and styles (x, y, color) | LWW per property | The latest drag is what the user sees and wants | Version checks that block every drag |
Counters and sets are the classic cases for data types that merge by design. The CRDTs lesson shows how a grow-only counter or an observed-remove set works inside; here, what matters is the API shape. If the client sends intent ("add tag sale", "decrease stock by 3") rather than state ("tags are now [sale]", "stock is now 47"), most conflicts disappear before they start.
4 changes silently lost
Try the lab with the two offline edits. With LWW per record, the later sync replaces everything, including the title that only the earlier editor touched. With LWW per field, the title survives, but price, stock, and tags, which both people changed, keep only the later value.
With a version check, nothing is lost, but the second editor has to redo their work. With the per-type rules, the stock changes add up, the tags are combined, and only the price waits for a person.
When merged values break the rules
A merge can be correct field by field and still produce a record that breaks a business rule. Two offline clerks each sell the last 3 units: stock 3, each sends -3, and the merged stock is -3. Each change was valid on its own copy. Together they oversold.
Mergeable types can't fix this, because the rule ("stock never below zero") spans both edits. The server must validate after merging and reject or flag the second write: "sold out while you were offline". For values like stock, seats, or money, offline edits should be treated as requests that the server can refuse, not as facts.
Decision You're designing a shared kanban board (cards with title, description, column, order, assignee, labels, and an estimate). How should conflicts be handled?
What we pick: Per-field rules. A board is mostly independent fields that many people touch in short bursts, so LWW per field in server order fits, with set operations for labels and a real text strategy for the description. Add a version check only to fields that carry real cost, such as an estimate used for billing.
Check your understanding · Exercise 6
Which rules fit these fields of an offline-capable expense app? Select all that apply.
Failure modes and edge cases
Conflict handling fails quietly, so you need to know what to look for. Here are the problems that show up in production, how you notice them, and the fix for each.
Silent loss you never hear about
Symptom: support tickets saying "my change didn't save", which nobody can reproduce. Cause: a whole-record PUT somewhere in the app, often an old screen or a bulk-edit tool. Fix: move every write to field-level PATCH, and log on the server when a write overwrites a field that changed since the client's base version. That log turns invisible losses into a number you can watch.
Delete versus edit, and zombies
Asha deletes a task. Ben, offline, edits its title. When Ben syncs, a naive server either fails with 404, losing his edit without explanation, or upserts the task, bringing it back with only a title: a zombie.
Pick one rule and apply it everywhere. For most apps, delete wins: keep a tombstone with the deletion version, drop later edits to it, and tell Ben ("This task was deleted by Asha") with a way to restore it.
Retry loops and conflict storms
A client that retries 412 automatically, without merging, can loop forever against a busy record, or worse, eventually win by resending stale data. Retry only after a successful three-way merge, cap the attempts (two or three), and fall back to the merge screen. If many clients hit the same record constantly, like a shared counter or a "last updated" field, the unit is too large; split it or switch to operations.
Offline queues replayed out of order
An offline client queues three edits to one record. If it sends them in parallel, they can arrive in any order, and each carries the same base version, so the second and third fail their checks against the first. Send queued writes for the same record one at a time, in order, updating the base version after each success. The background sync lesson covers the queue itself.
Derived and dependent fields
Some fields depend on each other: startDate and endDate, country and postcode, quantity and total. Merged per field, Asha's new start date and Ben's new end date can produce an end before the start. Treat dependent fields as one unit for conflict purposes, and validate the merged record before accepting it.
The UI that never learns
After a merge, the client must replace its base with the server's latest version and ETag. If it keeps the old base, the next save compares against stale data and either raises false conflicts or, with a broken check, overwrites again. Push updates (WebSockets or server-sent events) help: an open form that sees "product 7 is now v42" can merge early and show "Ben just changed the price" before the user even clicks Save.
Check your understanding · Exercise 7
After adding If-Match checks, users still report lost changes. Which causes are plausible? Select all that apply.
Using it in a system design interview
Conflicts come up in any design where two clients can write the same data: collaborative editors, admin dashboards, kanban boards, offline-first apps, shopping carts shared across devices, even a settings page open in two tabs. Interviewers rarely ask "how do you resolve conflicts?" directly. They wait to see if you notice the problem yourself.
When to bring it up
Raise it when you define the API or the data model. A sentence like "two people can edit the same card, so writes will be field-level PATCH with a version, and I'll decide per field what wins" shows you see the issue without derailing the interview. Come back to it in the deep dive if the interviewer picks offline support or collaboration.
What to draw
- A small sequence diagram: two clients, one server, two writes based on the same version, and the second answered with
412or merged. - A table of the main fields with a rule for each: LWW, operations, version check, or text CRDT.
- Where the base version lives on the client: in the form state, or in IndexedDB next to the offline queue.
How deep to go, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Names the lost update, sends only changed fields, knows If-Match and 412 exist. |
| Mid | Versions per record, a sensible client reaction to 412, and a merge screen for true conflicts with the user's draft kept. |
| Senior | Rules per field type, three-way merge with a kept base, delete versus edit with tombstones, server ordering instead of client clocks, and validation after merge. |
| Staff | Cost of each strategy for users and the business, metrics for conflict rates, when to move a feature to a CRDT or OT, and how the choice affects offline support and the API contract across teams. |
Common mistakes in interviews
- Saying "we'll use CRDTs" for a form-based app. It's heavy machinery for a problem that per-field writes and version checks solve.
- Resolving by client timestamps without mentioning clock skew.
- Detecting conflicts but not saying what the user sees afterwards.
- Forgetting deletes, so the design has no answer for an edit to a deleted item.
Summary
- A conflict is two changes made without knowledge of each other. The dangerous ones are silent: everything returns
200and work disappears. - Shrink the unit of conflict first. Field-level
PATCHremoves most false conflicts for free; Figma merges per property of each object. - Last write wins is fine for statuses, titles, positions, and colors, and wrong for counters, sets, long text, and money.
- Let the server decide what "last" means. Client clocks drift; if clients must order changes, use a hybrid logical clock with a device id tie-breaker.
- Version checks (
ETagplusIf-Match, answered with412, or a version in the body with409) detect conflicts without locks. A retry must merge first, never just resend. - A three-way merge needs the base both sides started from. Keep it, and only fields both sides changed differently need a decision.
- Merge screens keep the user's draft, show who changed what, list only true conflicts, and save with the new version.
- Choose a rule per field from what a lost change would cost, send intent instead of state, and validate business rules after merging.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Two admins edit the same product at the same time and one change disappears. Walk me through why and how you'd fix it.
It's a lost update. Both loaded the same version, each edited a copy, and each saved the whole object with PUT. The server stored the last request, so the earlier admin's change was overwritten with the stale values from the second form.
First fix: send only changed fields with PATCH, which removes conflicts between different fields. Second fix: give the product a version, return it as an ETag, and require If-Match on writes. A stale write gets 412, and the client fetches the latest version, runs a three-way merge against the base it kept, saves automatically if nothing overlaps, and shows a merge screen for fields both changed.
Question 2
Why is a three-way merge better than comparing the two conflicting versions?
Two copies only show that values differ, not who changed them. With the common ancestor you can tell, for each field, whether only I changed it, only they did, both did the same thing, or both did different things.
Only the last case is a true conflict. Everything else merges automatically, so users see far fewer conflicts. The cost is that the client must keep the base version it loaded, in memory or in IndexedDB for offline edits.
Question 3
An offline-first app resolves conflicts by the device's updatedAt timestamp. What's wrong with that?
Device clocks drift and can be set wrong, so a device with a fast clock wins every conflict, and a change made after seeing another change can still lose if its clock is behind. Equal timestamps also need a tie-breaker.
Better: let the server assign the order as writes arrive, or use a hybrid logical clock that never goes backwards and orders a change after everything its device has seen, with the device id as the final tie-breaker. And for fields where losing a change matters, don't pick a winner by time at all: use a version check or a mergeable type.
Question 4
How does Figma avoid most conflicts on a shared canvas?
It treats every object as a set of properties and merges each property separately with last write wins, in the order the server receives changes. Two people changing different properties, or different objects, never conflict. Only two people changing the same property of the same object do, and then the last value the server got wins, which is what people expect for a position or a color.
Clients also ignore incoming values for properties they have unconfirmed changes to, so the user's own edit doesn't flicker. The lesson is to pick the unit and the rule per kind of data instead of using one heavy algorithm for everything.
Question 5
Design conflict handling for a shared shopping cart used by a family on several phones.
Model the cart as a set of line items keyed by product id. Adding and removing items are operations on the set, so two people adding different items both succeed. Quantity changes are sent as increments, so two people each adding one more milk adds two.
Pick a rule for remove versus add of the same item (add wins is friendlier for a cart). At checkout, use a version check on the whole cart, because the payment must match exactly what the payer saw; if it changed, show the new cart and ask again. The server validates stock and prices after merging.
Practice it
These design problems lean on conflict resolution strategies.
- Design a recently viewed products featureEasy
- Design state sync across browser tabsMedium
- Design an admin CMSMedium
- Design a QR code event check-in appMedium
- Design a drag-and-drop form builderHard
- Design undo and redoHard
- Design a collaborative document editor like Google DocsHard
- Design a collaborative design tool like FigmaHard
- Design a whiteboard like ExcalidrawHard
- Design NotionHard
- Design an offline sync engineHard
References
- How Figma's multiplayer technology works Per-property last write wins with the server as the authority.
- MDN: If-Match Conditional writes for optimistic concurrency.
- MDN: 412 Precondition Failed The response to a failed
If-Match. - RFC 9110: HTTP Semantics, conditional requests The rules for
ETag,If-Match, and strong comparison. - RFC 7396: JSON Merge Patch A simple body format for field-level
PATCH. - RFC 6585: Additional HTTP Status Codes Defines
428 Precondition Required. - Git documentation: how merge conflicts are presented Conflict markers and the
diff3andzdiff3styles that show the base. - CockroachDB: time and hybrid logical clocks A production database that orders transactions with HLC timestamps.
- Logical Physical Clocks (Kulkarni et al., 2014) The paper that introduced hybrid logical clocks.
Read next
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- Operational transformation (OT)A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.