Sign in

How conflict resolution works in Liveblocks Sync

When several people edit the same data at the same time, Liveblocks Sync decides what the result should be. Most of the time you never notice this happening. Occasionally you’ll see a result you didn’t expect: an item that ends up in a different position than the one you chose, or a value that settles on someone else’s edit rather than yours. These outcomes follow a small set of rules, and once you know them you can predict how your data will merge, and structure it so that it merges the way you want.

Liveblocks Sync spans Storage, Presence, Broadcast, and Feeds. Conflict resolution only applies to Storage, not to Presence, Broadcast, or Feeds.

The server decides the order

Liveblocks Sync is not a peer-to-peer system in which clients talk directly to each other. Every room has a single point on our servers where changes are applied, one after another, in the order they arrive. That order is the authority: every client receives the same sequence of changes and replays it, which is why everyone ends up with the same view of the data.

This is what "last writer wins" means in practice. It doesn’t mean the last person to click, and it doesn’t mean the most recent change by wall-clock time. It means the last change to reach the server. When two people edit the same value a fraction of a second apart, the result can be decided by whose connection was faster rather than by who acted first.

Your changes apply immediately, then are confirmed

When you change something, it applies to your own copy of the data right away, before the server has seen it. The change is also queued as pending until the server confirms it.

While a change of yours is pending, Liveblocks can hold back other people’s changes to that same value, so it doesn’t briefly flip to their value and then back to yours. When your change is confirmed, the server’s authoritative version is applied. If someone else’s change to the same value arrived first, this is the moment your value wins over theirs.

Changes you make while offline are queued in the same way, and are sent when you reconnect.

You can tell whether your changes have been synchronized with the server with useSyncStatus.

import { useSyncStatus } from "@liveblocks/react/suspense";
function SaveIndicator() { const syncStatus = useSyncStatus({ smooth: true });
return syncStatus === "synchronizing" ? ( <span>Saving…</span> ) : ( <span>Saved</span> );}

Liveblocks only merges what it can see

Storage is a tree of Live structures and JSON values. LiveObject, LiveMap, LiveList, and LiveText are nodes in that tree, and can be nested inside one another. Everything else is a JSON value, and JSON values are always leaves: a Live structure can hold a JSON value, but no Live structure can sit underneath one.

Automatic conflict resolution applies only to Live structures. Even though a JSON value can also be an object or an array, it makes no difference to Liveblocks. JSON values are always considered opaque values, and Liveblocks never looks inside them or applies conflict resolution. They’re also immutable: you never edit a JSON value in place, you replace it with a new one, so every change is a change to the whole value. When two people change a JSON value the last edit will overwrite the other.

Because of this, most of the time you’ll want to use Live structures, so that people editing different parts of the same thing both keep their edits. But there are times when you want to deliberately treat a value as a single unit and opt-out of conflict resolution. Examples are a color value stored as { r, g, b }, or a 3D point stored as [x, y, z]. These values only make sense as a unit.

// A LiveObject: people drag one shape while someone else recolors it, so each// field has to merge on its owntype Shape = LiveObject<{ x: number; y: number; color: Color }>;
// A LiveList: people add todos at the same time, and every one should survivetype Todos = LiveList<string>;
// Not a LiveObject: r, g and b only mean anything together, and merging one// person’s red with another’s green would give you a yellow nobody pickedtype Color = { r: number; g: number; b: number };
// Not a LiveList: the three coordinates describe one position and always move// together, so replacing the point as a whole is what you wanttype Point = [x: number, y: number, z: number];

How each data structure resolves conflicts

Each structure resolves conflicts differently, and choosing the right one for your data is the main lever you have over how it merges.

StructureWhen two people change the same thing
LiveObjectThe last change to reach the server wins, per key
LiveMapThe last change to reach the server wins, per key
LiveList.insert, LiveList.pushBoth items are kept, and the server decides their order
LiveList.setThe last change to reach the server wins, per position
LiveTextBoth sets of edits are kept and merged
LiveFileImmutable, the last change to reach the server wins
Any JSON valueImmutable, the last change to reach the server wins

LiveObject and LiveMap resolve conflicts per key

Conflicts are resolved per key, not per structure. If one person changes a shape’s x while another changes its fill, both changes survive. They never conflict, because they touch different keys.

When two people write to the same key, one of the two values is kept whole and the other is overwritten. Values are not merged, so if both people write an object to the same key, the result is one of the two objects rather than a combination of them. Writing a plain value to a key that currently holds a nested structure removes that structure and everything inside it.

Should I use LiveObject or LiveMap?

Choosing whether to use LiveObject or LiveMap for your application is not a conflict resolution decision, since they behave identically in this area. Use a LiveObject for a record with a fixed set of known keys whose values have different types, such as a shape with x, y, and fill. Use a LiveMap for an index of many entries that share the same shape and whose keys you don’t know ahead of time, such as every shape on a canvas keyed by id.

LiveList items have positions, not indexes

An item in a LiveList doesn’t know its index. It carries a position, and the list is ordered by sorting those positions. Inserting an item means choosing a position between its two neighbors.

This is why two people inserting at index 0 at the same moment both keep their item. Neither insert overwrites the other. The server decides which position comes first, and the item that arrives second is placed immediately after the first.

The push method on LiveList is a special case, because it’s resolved on the server against the real end of the list, and guaranteed to be placed after every existing item. This means push(item) and insert(item, list.length) are not equivalent when several people are editing: insert chooses a position based on the list as that client currently sees it, which may already be out of date. Use push when you mean to append.

The set(index, item) method replaces the item at that position rather than editing it, so two people calling set on the same index produce a single item rather than a merge of the two. move rewrites an item’s position, and doesn’t record an intention such as "put this after B". Starting from ["A", "B", "C"], if you move A to the end while someone else moves B to the end at the same time, both moves are applied as positions, and everyone ends up with ["C", "B", "A"]. Only one of the two items can actually be last, so the person who moved B doesn’t get the result they asked for.

LiveText adjusts positions instead of choosing a winner

Every structure above resolves a conflict by keeping one value and discarding the other. With LiveText, however, instead of choosing between the two changes, the Liveblocks server rewrites the edit positions in cases of conflict in whichever change arrives second, so that it still means what its author meant, and applies both changes.

Say two people start from "Hello world", and Alice inserts "big " at index 6 while Bob inserts "!" at index 11.

If Bob’s edit reaches the server first, the text becomes "Hello world!". Alice’s index 6 is before his insert, so it still points where she meant it to, and her edit applies unchanged.

If Alice’s edit reaches the server first, the text becomes "Hello big world". Bob’s index 11 now points four characters too early, so it’s rewritten to 15 before his edit is applied.

Either way the result is "Hello big world!", and neither person lost what they typed.

Inline formatting is the exception to this. If two people apply the same attribute to the same characters at once, the change that reaches the server last wins. Attributes applied by different people in different locations in the LiveText both survive.

This is the reason to hold text in a LiveText rather than in a string field. When two people type into the same string key, one of the two values is kept and the other is overwritten, so one person’s sentence is lost. When they type into the same LiveText, both sets of edits are present in the result.

import type { LiveObject, LiveText } from "@liveblocks/client";
// A string key: one of the two edits is overwrittentype NoteWithString = LiveObject<{ body: string }>;
// A LiveText: both edits are kepttype NoteWithLiveText = LiveObject<{ body: LiveText }>;

A LiveText holds a flat sequence of text with optional inline attributes such as { bold: true }. It can’t contain other Live structures, so it is always a leaf in the tree. For the choice between LiveText and Yjs, see LiveText vs Yjs.

Results that surprise people

An item ends up in a different position than the one you chose

Two people added an item to the same place in a list at the same time, and yours appears after theirs even though you clicked first.

Both clients chose the same position, and the one whose change reached the server second was placed immediately after the first. Both items are kept, which is usually what you want. If you meant to add to the end of the list, use push rather than insert, because push always appends after every existing item.

Two moves produce an order neither person chose

You dragged an item to the end of a list, and it isn’t at the end.

This is the move behaviour described above: because a move is stored as a position rather than as an intention, two people moving different items to the same place are resolved independently rather than combined. Everyone sees the same list, but not necessarily the list either person had in mind. If the exact order carries meaning that has to survive concurrent edits, store it explicitly rather than relying on move alone.

An edit applies on your screen but never reaches anyone else

You changed a nested object and can see the change, but nobody else does, and it disappears on reload.

Another client replaced that object while you were holding a reference to it. The replaced object is no longer part of Storage, so changes made through it apply to your own copy, are never synced, and Liveblocks logs a warning.

This only bites when a reference outlives the mutation that read it, which in React usually means keeping a Live structure in state. Store the id instead, and read the structure from Storage each time you change it.

// ❌ Avoid: holds a node that another client may replaceconst [shape, setShape] = useState<LiveObject<Shape> | null>(null);
// ✅ Prefer: holds the id, and reads the node at the point it changes itconst [shapeId, setShapeId] = useState<string | null>(null);
const setFill = useMutation(({ storage }, id: string, fill: string) => { storage.get("shapes").get(id)?.set("fill", fill);}, []);

Undo replays, it doesn’t rebase

Undo applies the inverse of your original change as a new change, without taking into account what has happened since. Most of the time this is exactly what you want. But if someone else has changed the same value since your original edit, undoing yours can overwrite their newer value.

Learn more

For more information about the data structures in Liveblocks Sync and how they behave, check out these resources: