8 min read ·
Last write wins, and somebody loses
Bob draws an arrow on a shared whiteboard, watches it land, and goes back to the call. Ten seconds later the arrow is gone, and nobody in the room meant to delete it.
I know exactly how that happens, because I wrote the code that does it.
SketchSync is a real-time whiteboard I build on the side: Next.js in the browser, a small Socket.IO server that keeps each room in memory. It has a select tool. You drag a box around part of the drawing, click move, and drag those pixels somewhere else. When you place them, the app sends two messages: erase the old rectangle, then paste the image at the new spot.
Now put two people in the room. Alice selects a corner of the board and starts dragging. While she holds it, Bob draws his arrow inside that corner. Alice drops.
The server receives Bob's stroke, then Alice's erase, then Alice's paste. It stamps each one with its own clock, and every browser replays them in that order. The erase lands on top of Bob's arrow. The pasted image is a copy of the corner from before Bob drew. His arrow is gone from both places.
Every message succeeded. Bob got his confirmation. Alice got hers. No error, no warning, no red popup. The arrow is even still in the server's memory, faithfully stored, underneath an eraser.
That's last write wins. When two writes touch the same thing, whichever one the system puts last replaces the other. On my board, last means arrived later. It's the policy you get when you never choose one.
"Later" needs a single clock
SketchSync gets one thing right. The server stamps every move with its own clock, so "later" means "arrived at this one machine later." Everyone sees the same order.
Let each client stamp its own writes and even that goes. Bob's laptop runs two seconds fast, so his stroke "happens" after Alice's erase even though he drew it first. This time the drift saves his arrow by accident. Flip it, and the erase kills a stroke he drew after Alice let go. Separate machines have no shared now. I wrote a whole post about that.
A database that picks winners by the clients' timestamps is picking winners by whose clock drifted which way. Kyle Kingsbury breaks databases for a living. In 2013 he said it bluntly about Cassandra, a widely used one: a last-write-wins register, one slot that holds one value, "preserves whichever arrives with the highest timestamp," whether or not that write is the one a human would call the latest.
But a perfect clock wouldn't have saved Bob's arrow. My server's order was correct. Bob really did draw first.
The order was right, the meaning was wrong
Alice never meant "erase this rectangle." She meant "move these marks." The message only said the first part, and it said it about a board that no longer existed. By the time it arrived, the rectangle held something she had never selected.
Google described the same trap for text in a 2010 post on how collaboration in Google Docs works. John deletes characters 9 to 11. Meanwhile Luiz has typed two characters at the start of the document. If Luiz applies John's delete exactly as sent, he removes the wrong characters, because everything shifted two places to the right. The fix is to transform John's change against Luiz's before applying it: move the delete over by two. That's operational transformation, or OT, and it's how Google Docs lets many people type into one document without eating each other's words.
My erase rectangle is John's delete with no transform. A position-based instruction, applied to a document that moved underneath it.
Databases have a name for this: the lost update. You read a value, think about it, write the result back, and flatten whatever changed while you were thinking. Everybody learns it with bank balances. Nobody expects it from a drawing app.
The menu
There's no free answer. There's a menu, and every item costs something.
You can lock. Alice picks up the corner and the corner is hers until she drops it. Bob sees it greyed out and can't draw there. It's simple and honest and a little rude, and you now need a story for what happens when Alice closes her laptop mid-drag.
You can make last write wins smaller. Figma's servers keep the latest value for each property of each object: last write wins, but at the level of one color or one x coordinate. Co-founder Evan Wallace: "A conflict happens when two clients change the same property on the same object." So if I recolor a rectangle while you move it, both changes survive. We only collide when we both drag the same rectangle, and then the loser sees it happen, right under their cursor. Figma looked at OT and called it "unnecessarily complex for our problem space."
Notice what that needs: objects. Figma knows a rectangle is a rectangle. My board stores strokes and pixels, and moving a selection is a cut-and-paste of pixels. There's no object for two edits to meet on, so there's nothing to merge. My data model picked my conflict policy before I wrote a single line of sync code.
You can transform, which is OT again. It's proven for text, painful for anything richer, and Google's version sends every change through a central server.
Or you can merge. A CRDT, a conflict-free replicated data type, is a data structure built so that copies edited separately always converge to the same state once they sync. Libraries like Yjs give you shared maps, lists and text that merge themselves. Two people add items to one list, and both keep their items.
Then read the fine print. Ink & Switch's local-first essay, the best introduction to this world, says it outright: "The only type of change that a CRDT cannot automatically resolve is when multiple users concurrently update the same property of the same object." In that case the CRDT keeps the conflicting values "and leaves it to be resolved by the application or the user." So somebody still has to pick. Yjs's shared map picks for you: it breaks the tie by comparing the two clients' random IDs, so every copy agrees on one winner.
Agreement is not fairness. A random number just decided whose edit survived.
The pipe has an opinion too
Socket.IO, which carries SketchSync's messages, is refreshingly clear about what it promises. Messages arrive in order, if they arrive. By default delivery is "at most once": if the connection breaks while an event is in flight, "there will be no retry upon reconnection."
So the pipe is also picking losers, and I let it, on purpose, per kind of data. The server relays cursor positions as volatile events, which Socket.IO simply drops when the connection isn't ready. A stale cursor is worthless, so losing one costs nothing. Strokes are different. When the connection drops, the client forgets every stroke the server hasn't confirmed, rejoins the room, and redraws everything from the server's copy. If your stroke didn't make it, you watch it vanish.
That's harsh, but it's honest. The board you see is the board everyone else sees. The alternative is to retry, and a retry is a write, with all the "did that already happen?" baggage that comes with it.
The question nobody writes down
What should happen when two people move the same thing at the same time?
You can merge: fine for a shopping list, absurd for a sticky note that ends up halfway between two corners. You can pick: the first, the last, the owner, the person who's presenting. You can keep both and ask. You can stop it from happening with a lock.
Every one of those is defensible. Not choosing isn't, because not choosing is choosing last write wins, and last write wins deletes someone's work without telling them.
This belongs in the spec, next to the empty screens and the error messages. A product manager can answer "what happens when two people move one note?" in five minutes. Nobody asks, so the network answers instead, one request at a time, by arrival order.
For my board the fix is small. The erase should carry what Alice saw: clear this rectangle, but only if nothing new was drawn in it since she picked it up. If Bob drew there, the server refuses, and Alice gets a message instead of a silent victory. Databases call that a compare-and-set. I call it the server finally admitting that two people were in the room.
Somebody always loses a conflict. The only choice you get is whether they find out.
