cf.completefrontend
My progress
FUNDAMENTALS · MODULE 6

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.

  1. Asha opens the product. Her form loads price: 10, stock: 50.
  2. Ben opens the same product a few seconds later. His form loads the same values.
  3. Asha changes the price to 12 and clicks Save. The client sends the whole form with PUT /products/7: price: 12, stock: 50.
  4. 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.
  5. 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.
A sequence diagram between Asha, the server, and Ben. Both load product 7 with price 10 and stock 50. Asha saves the whole product with PUT: price 12, stock 50, and gets 200 OK. Ben then saves the whole product from his old copy: price 10, stock 40, and also gets 200 OK. The server now holds price 10, stock 40, and Asha's price change is lost with no error anywhere.AshaServerBenprice 10, stock 50price 10, stock 50PUT price 12, stock 50200 OKPUT price 10, stock 40200 OKprice 10, stock 40Asha's price change is gone, and nobody was told
Every request succeeded. Ben never touched the price, but his 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 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.

  1. 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.
  2. Who decides the order? The client's clock, the server's arrival order, or a version number. This decides what "last" means.
  3. 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.

Two panels side by side. Left, the unit of conflict is the whole record: Asha writes product 7, Ben writes product 7, the writes collide, and the result is price 10, stock 40, with Asha's price lost. Right, the unit is one field: Asha writes the price field, Ben writes the stock field, there is no collision, and the result is price 12, stock 40, with both kept.unit: the whole recordunit: one fieldAsha writes product 7price 12, stock 50Ben writes product 7price 10, stock 40price 10, stock 40collision: price lostAsha writes pricePATCH price 12Ben writes stockPATCH stock 40price 12, stock 40no collision: both kept
Same people, same edits, same rule (last write wins). Only the unit changed. Most conflicts in business apps are false conflicts like this one.

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

Strategies differ mainly in who pays for a conflict: the earlier writer, the later writer, or the user who must decide.
StrategyWhat happens on a true conflictWhat can be lostGood for
Last write wins (LWW)The later write replaces the earlier oneThe earlier value, silentlyStatuses, titles, colors, positions
Version check (optimistic concurrency)The later write is refused with 412 or 409Nothing, but the user must redo or mergePrices, stock, contracts, anything costly
Three-way mergeNon-overlapping changes merge; overlaps are flaggedNothing, if overlaps go to a personForms, config files, documents with structure
Merge screenA person sees both versions and decidesNothing, but it costs the user timeRare, valuable conflicts
Mergeable data typesBoth changes are combined by ruleNothing for the rule's intentCounters, 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.

ts
// 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),
});
Field-level writes. Compare against what was loaded, not against the server's current value, which the client doesn't know.

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.

On the left, two patches in the order the server received them: number 41 from Asha sets price to 12; number 42 from Ben sets price to 11 and stock to 40. On the right, the server's copy of the product, one entry per field with the write that last set it: name Desk lamp from the base, number 40; price 11 from Ben, number 42; stock 40 from Ben, number 42; color black from the base, number 40. Asha's price of 12 was replaced, because Ben's write to the same field came later.patches, in server orderserver copy, one entry per field#41, from Ashaprice 12#42, from Benprice 11, stock 40nameDesk lampbase, #40price11Ben, #42stock40Ben, #42colorblackbase, #40price: Asha's 12 (#41) replaced by Ben's 11 (#42)
Each field remembers which write set it, and a later write to the same field replaces it. Ben's 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 write likes: 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 tag new. Each writes the full array, and one tag is lost. Send add and remove operations 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.

A timeline of real time from 10:00 to 10:06. Ben's clock runs 5 minutes fast: he edits the field at real time 10:00, and his device stamps it 10:05. Asha's clock is correct: she edits the same field later, at real time 10:02, stamped 10:02. Sorting by timestamp, Ben wins because 10:05 is greater than 10:02, which throws away the newer change. Using the server's order, Asha wins because her write arrived last.Ben, clock 5 minutes fastBedit, stamped 10:05Asha, clock correctAedit, stamped 10:02real time10:0010:0210:0410:06sort by stampBen wins: 10:05 beats 10:02server orderAsha wins: hers arrived last
The later edit carries the smaller timestamp. A rule that trusts device clocks lets the fast clock win, which is why the server, or a logical clock, should decide the order.

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.

ts
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 never goes backwards, and anything a device has seen is ordered before its next change. The node id breaks exact ties the same way on every device.

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.

http
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
Asha's write moves the record to v42. Ben's write still names v41, so the server refuses it instead of overwriting Asha.
A state machine for one save. Editing, with the base version v41 kept, goes to Saving on save, sending If-Match v41. Saving goes to Saved on 200 with the new ETag v42. Saving goes to Conflict on 412, carrying the latest version. Conflict goes to Merging after fetching the latest copy, where a three-way merge of base, mine, and theirs runs. If nothing overlaps, Merging goes straight back to Saving with the new If-Match. If a field overlaps, Merging goes to Asking the user on a merge screen, and once the user picks, back to Saving.Editingbase v41 keptSavingIf-Match v41Saved200, ETag v42Conflict412 + latestMergingbase, mine, theirsAsking usermerge screensave200412fetchno overlapsame field changed on both sidesuser picks
The 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.

text
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
Most ORMs have this built in, often called a version column or optimistic locking.

412, 409, and 428

Pick one convention for version conflicts and document it in your API contract; the client code must branch on it.
StatusMeansWhen to use it
412 Precondition FailedA condition in the request headers (If-Match) is falseThe version check failed. The standard answer for If-Match.
409 ConflictThe request clashes with the current state of the resourceMany 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 RequiredThis write must be conditionalThe 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:

  1. 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.
  2. Ask. If the same field was changed on both sides, show a merge screen with both values and let the user choose.
  3. 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-Match uses 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 ETag response header on cross-origin requests if the server lists it in Access-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.

A graph of versions. The base, price 10 and stock 50, has two children: mine, which changed the price to 12, and theirs, which changed the stock to 40. Both point to the merged version, price 12 and stock 40. A note says that without the base, price and stock both differ between mine and theirs, and there is no way to tell who changed which.baseprice 10, stock 50mineprice 12stock 50theirsprice 10stock 40changed pricechanged stockmergedprice 12, stock 40without the base:both fields differ,but who changed which?
Comparing each side with the base shows that mine only changed the price and theirs only changed the stock, so both changes are kept. Git does the same with lines, starting from the merge base.

The rules, per field

Only the last row needs a decision. In typical forms it is a small fraction of all edits.
Base vs mineBase vs theirsResult
samesameNobody changed it: keep it
samechangedOnly they changed it: take theirs
changedsameOnly I changed it: take mine
changedchanged, same value as mineWe agree: take it, no conflict
changedchanged, different valueTrue 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.

ts
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.

Your copy:
Their copy:
A three-way merge of a task. title: base Launch, mine Launch v2, theirs Launch, took mine: Launch v2. due: base May 3, mine May 10, theirs May 12, needs a person. owner: base Asha, mine Asha, theirs Ben, took theirs: Ben. 2 merged by themselves, 1 needs a person.baseminetheirsmergedtitleLaunchLaunch v2LaunchLaunch v2dueMay 3May 10May 12?ownerAshaAshaBenBen2 merged by themselves, 1 needs a person
Changed values are outlined. With the base, the merge takes every change made on one side only and asks only about fields both sides changed differently. Without it, every difference needs a person.
due:

Beyond flat records

  • Nested objects. Recurse: merge an address object field by field rather than as one value, so a changed city and a changed zip both 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.
A wireframe of a merge screen titled Edit product 7. A banner says Ben changed this product 2 minutes ago, and 2 other changes were combined. One field needs a choice: Price, with two options, yours 12 and Ben's 11, and an edit box below them. Below that, merged fields are listed as done: stock 40 from Ben, and name from you. At the bottom, a Save merged version button and a Cancel button.Edit product 7Ben changed this product 2 minutes ago.2 other changes were combined.Price: choose one(o) yours: 12( ) Ben's: 11or type a valueMerged by themselvesstock: 40 (Ben)name: yoursSave merged versionCancel
Only the true conflict asks for a decision. Everything that merged cleanly is shown as done, the user's draft is kept, and the save goes out with the new version.

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).

text
<<<<<<< ours
Due date: 15 October
||||||| base
Due date: 30 September
=======
Due date: 1 October
>>>>>>> theirs
A Git conflict with the base shown. The middle section is what makes it possible to see who changed what.

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.

Both keep every change until a person acts. They differ in when the person must act and how much they must understand.
QuestionGit conflict markersGoogle Docs suggestions
When the person decidesAfter the clash, before anything else can continueWhenever the owner reviews, the document stays usable
What they seeBoth versions as raw text, optionally with the baseThe change inline, with author and a comment thread
UnitLinesAny span of text
Who it suitsDevelopers with merge toolsWriters, 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?

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

The rule follows the cost of a lost change, not the importance of the record.
DataGood defaultWhyWhat to avoid
Counters (likes, stock moves, votes)Send operations (+1, -3) and add them upTwo increments both count; order doesn't matterSending the new total, which loses one side
Sets (tags, members, labels)Add-wins set: merge adds and removes per itemTwo people adding different tags both keep theirsWriting the whole array as one LWW value
Booleans and enums (done, status)LWW per field, server orderThe newest choice is usually the intended oneMerge screens for every flip; it's noise
Short text (titles, names)LWW per field, maybe a noticeRarely edited at once; easy to redoCharacter merging, which can produce odd mixes like "Q3 roadmapplan"
Long text (descriptions, docs)Text CRDT or OT if live; line merge plus review if notEdits in different places should both surviveOne LWW string for the whole body
Money and legal values (price, refund, terms)Version check, then a person decidesA silent wrong value costs money or trustAny automatic winner
Positions and styles (x, y, color)LWW per propertyThe latest drag is what the user sees and wantsVersion 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.

Strategy:
Syncs first:
Product before: title Lamp, price 20, stock 50, tags lamp. Offline, Asha renames it Brass lamp, sets the price to 18, sells 3, and adds the tag sale. Ben sets the price to 22, restocks 10, and adds the tag new. Asha syncs first, then Ben. With LWW per record: title Lamp, Asha's edit lost; price 22, Asha's edit lost; stock 60, Asha's edit lost; tags lamp, new, Asha's edit lost. 4 changes silently lost.beforeAsha, offlineBen, offlineresulttitleLampBrass lampsameLampAsha's edit lostprice20182222Asha's edit loststock50476060Asha's edit losttagslamplamp, salelamp, newlamp, newAsha's edit lost4 changes silently lost
Lost values are marked red; fields waiting for a person or a redo are hatched. Try the same strategy with the other person syncing first: for last write wins, the order decides who loses.

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?

LWW per card

Each save sends the whole card with PUT

The last save replaces the card

Pros

  • Trivial to build
  • No merge code anywhere

Cons

  • Moving a card and editing its title at once loses one of them
  • Label edits clobber each other
  • Users see changes vanish

Version check per card

Each card has a version; saves send If-Match

On 412, show a merge screen

Pros

  • Nothing is lost silently
  • Simple server rule

Cons

  • Every drag between columns can fail during a busy meeting
  • Merge screens for trivial clashes annoy users

Per-field rules, Figma style

Column, order, assignee, title, estimate: LWW per field in server order

Labels: add and remove operations

Description: a text CRDT, or a version check if editing is rare

Pros

  • Almost no visible conflicts
  • Each field loses at most what users expect
  • Live updates stay smooth

Cons

  • More code: per-field patches, set operations, and a text strategy
  • Needs a clear delete rule (tombstones)

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.

A decision flowchart for one field. First question: is it text that several people edit at once? Yes leads to a text CRDT or OT. No leads to the second question: is it a counter or a set? Yes leads to sending operations, such as plus 1 or add tag. No leads to the third question: would a lost change cost money or trust? Yes leads to a version check plus a merge screen. No leads to last write wins per field, in server order.Shared live text?many people typing at onceText CRDT or OTCounter or set?votes, stock moves, tagsSend operations+1, add tagLoss costs money?price, refund, contractVersion checkthen a merge screenLWW per fieldserver orderyesnoyesnoyesno
Ask the questions in order. Most fields end at the bottom right: last write wins per field is the right default for the common case, and the other boxes are the exceptions.

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 412 or 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

LevelWhat a strong answer includes
JuniorNames the lost update, sends only changed fields, knows If-Match and 412 exist.
MidVersions per record, a sensible client reaction to 412, and a merge screen for true conflicts with the user's draft kept.
SeniorRules 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.
StaffCost 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 200 and work disappears.
  • Shrink the unit of conflict first. Field-level PATCH removes 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 (ETag plus If-Match, answered with 412, or a version in the body with 409) 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.

Question 2

Why is a three-way merge better than comparing the two conflicting versions?

Question 3

An offline-first app resolves conflicts by the device's updatedAt timestamp. What's wrong with that?

Question 4

How does Figma avoid most conflicts on a shared canvas?

Question 5

Design conflict handling for a shared shopping cart used by a family on several phones.

Practice it

These design problems lean on conflict resolution strategies.

References

Read next