| layout | default |
|---|---|
| title | Liveblocks - Chapter 3: Storage & Conflict Resolution |
| nav_order | 3 |
| has_children | false |
| parent | Liveblocks - Real-Time Collaboration Deep Dive |
Welcome to Chapter 3: Storage & Conflict Resolution. In this part of Liveblocks - Real-Time Collaboration Deep Dive, you will build an intuitive mental model first, then move into concrete implementation details and practical production tradeoffs.
Presence tells you who is here. Storage tells you what they are working on together. Liveblocks Storage is a persistent, real-time data layer that automatically resolves conflicts when multiple users edit the same data simultaneously. It is powered by CRDTs (Conflict-free Replicated Data Types), the same class of algorithms used by Figma, Apple Notes, and other world-class collaborative tools.
This chapter covers the three core storage primitives -- LiveObject, LiveList, and LiveMap -- how conflicts are resolved under the hood, and how to structure your data for optimal collaboration.
graph TD
subgraph "Client A"
CA_LOCAL[Local Copy]
CA_OPS[Pending Operations]
end
subgraph "Client B"
CB_LOCAL[Local Copy]
CB_OPS[Pending Operations]
end
subgraph "Liveblocks Server"
SERVER_CRDT[CRDT Resolution Engine]
SERVER_STORE[(Persistent Storage)]
SERVER_BROADCAST[Broadcast to Clients]
end
CA_OPS -->|Send ops| SERVER_CRDT
CB_OPS -->|Send ops| SERVER_CRDT
SERVER_CRDT --> SERVER_STORE
SERVER_CRDT --> SERVER_BROADCAST
SERVER_BROADCAST -->|Apply remote ops| CA_LOCAL
SERVER_BROADCAST -->|Apply remote ops| CB_LOCAL
style SERVER_CRDT fill:#fce4ec,stroke:#c62828
style SERVER_STORE fill:#e8f5e9,stroke:#2e7d32
Storage uses an operation-based CRDT model:
- Each client maintains a local copy of the data
- Mutations generate operations (ops) that are sent to the server
- The server applies ops deterministically and broadcasts them to all clients
- All clients converge to the same state regardless of operation order
LiveObject is a mutable JSON-like object. It supports nested properties and conflict resolution at the property level.
import { LiveObject } from "@liveblocks/client";
// Define the shape
type Shape = {
x: number;
y: number;
width: number;
height: number;
fill: string;
rotation: number;
};
// Create an instance
const shape = new LiveObject<Shape>({
x: 0,
y: 0,
width: 100,
height: 100,
fill: "#3b82f6",
rotation: 0,
});
// Reading values
const x = shape.get("x"); // 0
const fill = shape.get("fill"); // "#3b82f6"
// Updating values
shape.set("x", 50);
shape.set("fill", "#ef4444");
// Updating multiple properties at once
shape.update({
x: 100,
y: 200,
rotation: 45,
});
// Converting to plain JSON
const json = shape.toImmutable();
// { x: 100, y: 200, width: 100, height: 100, fill: "#ef4444", rotation: 45 }Conflict resolution for LiveObject: When two users update different properties simultaneously, both changes are preserved. When they update the same property, the last write wins (determined by server-received order).
sequenceDiagram
participant A as Client A
participant S as Server
participant B as Client B
Note over A,B: Initial state: { x: 0, y: 0, fill: "blue" }
A->>S: set("x", 100)
B->>S: set("y", 200)
Note over S: No conflict: different properties
S->>A: apply: set("y", 200)
S->>B: apply: set("x", 100)
Note over A,B: Final state: { x: 100, y: 200, fill: "blue" }
A->>S: set("fill", "red")
B->>S: set("fill", "green")
Note over S: Conflict! Same property.<br/>Server picks order: red first, green second
S->>A: apply: set("fill", "green")
S->>B: apply: set("fill", "green")
Note over A,B: Final state: { fill: "green" } (last-write-wins)
LiveList is an ordered, mutable list. It supports insertions, deletions, moves, and updates at any position. Unlike arrays, concurrent insertions at different positions never conflict.
import { LiveList, LiveObject } from "@liveblocks/client";
type Todo = {
id: string;
text: string;
completed: boolean;
author: string;
};
// Create a list of LiveObjects
const todos = new LiveList<LiveObject<Todo>>([
new LiveObject({
id: "todo-1",
text: "Design the schema",
completed: true,
author: "Alice",
}),
new LiveObject({
id: "todo-2",
text: "Implement storage",
completed: false,
author: "Bob",
}),
]);
// Reading
const length = todos.length; // 2
const first = todos.get(0); // LiveObject<Todo>
const firstText = first?.get("text"); // "Design the schema"
// Adding items
todos.push(
new LiveObject({
id: "todo-3",
text: "Write tests",
completed: false,
author: "Charlie",
})
);
todos.insert(
new LiveObject({
id: "todo-4",
text: "Review PR",
completed: false,
author: "Alice",
}),
1 // Insert at index 1
);
// Removing items
todos.delete(0); // Remove first item
// Moving items
todos.move(0, 2); // Move item from index 0 to index 2
// Iterating
todos.forEach((todo, index) => {
console.log(`${index}: ${todo.get("text")}`);
});
// Converting to plain array
const plainTodos = todos.toImmutable();Conflict resolution for LiveList: Liveblocks uses a positional CRDT for lists. Concurrent insertions at the same position both succeed and appear in a deterministic order. Deletions and moves are also resolved without conflicts.
| Operation by A | Operation by B | Result |
|---|---|---|
| Insert "X" at 0 | Insert "Y" at 0 | Both inserted, deterministic order |
| Delete index 2 | Update index 2 | Item deleted (delete wins) |
| Move 0 to 3 | Move 0 to 1 | Last-processed move wins |
| Insert "X" at end | Insert "Y" at end | Both appended |
LiveMap is a key-value map with string keys. It is ideal for collections where items have natural IDs (shapes on a canvas, users in a session, etc.).
import { LiveMap, LiveObject } from "@liveblocks/client";
type Shape = {
x: number;
y: number;
width: number;
height: number;
fill: string;
type: "rectangle" | "ellipse" | "triangle";
};
// Create a map of shapes
const shapes = new LiveMap<string, LiveObject<Shape>>();
// Adding entries
shapes.set(
"shape-1",
new LiveObject<Shape>({
x: 0,
y: 0,
width: 100,
height: 100,
fill: "#3b82f6",
type: "rectangle",
})
);
// Reading
const shape = shapes.get("shape-1"); // LiveObject<Shape> | undefined
const size = shapes.size; // 1
const hasShape = shapes.has("shape-1"); // true
// Updating a nested LiveObject
shape?.update({ x: 50, y: 75 });
// Deleting
shapes.delete("shape-1");
// Iterating
shapes.forEach((shape, key) => {
console.log(key, shape.toImmutable());
});
// Converting to plain object
const plainShapes = Object.fromEntries(
Array.from(shapes.entries()).map(([key, value]) => [key, value.toImmutable()])
);Conflict resolution for LiveMap: Adding or updating the same key follows last-write-wins semantics. Adding different keys never conflicts. Deleting a key that another user is updating results in the delete winning.
Storage primitives can be nested to model complex data:
import { LiveObject, LiveList, LiveMap } from "@liveblocks/client";
// A collaborative whiteboard
type Storage = {
// Document metadata
document: LiveObject<{
title: string;
createdAt: string;
lastEditedBy: string;
}>;
// Shapes on the canvas (keyed by ID)
shapes: LiveMap<
string,
LiveObject<{
type: "rectangle" | "ellipse" | "text";
x: number;
y: number;
width: number;
height: number;
fill: string;
content?: string;
}>
>;
// Layer ordering (front to back)
layers: LiveList<string>;
// Chat messages
messages: LiveList<
LiveObject<{
id: string;
author: string;
text: string;
timestamp: number;
}>
>;
};graph TD
ROOT[Storage Root]
DOC[LiveObject: document]
SHAPES[LiveMap: shapes]
LAYERS[LiveList: layers]
MSGS[LiveList: messages]
ROOT --> DOC
ROOT --> SHAPES
ROOT --> LAYERS
ROOT --> MSGS
DOC --> TITLE["title: string"]
DOC --> CREATED["createdAt: string"]
SHAPES --> S1["'shape-1' -> LiveObject"]
SHAPES --> S2["'shape-2' -> LiveObject"]
S1 --> S1X["x: 0, y: 0, fill: '#3b82f6'"]
S2 --> S2X["x: 100, y: 50, fill: '#ef4444'"]
LAYERS --> L1["[0]: 'shape-2'"]
LAYERS --> L2["[1]: 'shape-1'"]
MSGS --> M1["[0]: LiveObject {text: 'Hello!'}"]
style ROOT fill:#e3f2fd,stroke:#1565c0
style SHAPES fill:#e8f5e9,stroke:#2e7d32
style LAYERS fill:#fff3e0,stroke:#e65100
style MSGS fill:#fce4ec,stroke:#c62828
The useStorage hook reads from storage with a selector:
import { useStorage } from "../liveblocks.config";
function DocumentTitle() {
const title = useStorage((root) => root.document.title);
return <h1>{title}</h1>;
}
function ShapeCount() {
const count = useStorage((root) => root.shapes.size);
return <span>{count} shapes</span>;
}
function TodoList() {
const todos = useStorage((root) =>
root.todos.map((todo) => ({
id: todo.id,
text: todo.text,
completed: todo.completed,
}))
);
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<span style={{
textDecoration: todo.completed ? "line-through" : "none"
}}>
{todo.text}
</span>
</li>
))}
</ul>
);
}The useMutation hook provides write access to storage. All mutations run inside a callback that receives the mutable storage root:
import { useMutation } from "../liveblocks.config";
import { LiveObject } from "@liveblocks/client";
import { nanoid } from "nanoid";
function TodoApp() {
const todos = useStorage((root) =>
root.todos.toImmutable()
);
const addTodo = useMutation(({ storage }, text: string) => {
const todos = storage.get("todos");
todos.push(
new LiveObject({
id: nanoid(),
text,
completed: false,
author: "current-user",
})
);
}, []);
const toggleTodo = useMutation(({ storage }, index: number) => {
const todos = storage.get("todos");
const todo = todos.get(index);
if (todo) {
todo.set("completed", !todo.get("completed"));
}
}, []);
const deleteTodo = useMutation(({ storage }, index: number) => {
const todos = storage.get("todos");
todos.delete(index);
}, []);
const moveTodo = useMutation(
({ storage }, fromIndex: number, toIndex: number) => {
const todos = storage.get("todos");
todos.move(fromIndex, toIndex);
},
[]
);
return (
<div>
<AddTodoForm onAdd={addTodo} />
<ul>
{todos?.map((todo, index) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(index)}
/>
<span>{todo.text}</span>
<button onClick={() => deleteTodo(index)}>Delete</button>
</li>
))}
</ul>
</div>
);
}When you need to perform multiple mutations atomically, use useBatch:
import { useMutation } from "../liveblocks.config";
import { LiveObject } from "@liveblocks/client";
import { nanoid } from "nanoid";
function Canvas() {
// Multiple operations batched into a single undo step
const duplicateShape = useMutation(({ storage }, shapeId: string) => {
const shapes = storage.get("shapes");
const layers = storage.get("layers");
const original = shapes.get(shapeId);
if (!original) return;
const newId = nanoid();
const clone = new LiveObject({
...original.toImmutable(),
x: original.get("x") + 20,
y: original.get("y") + 20,
});
// Both ops are batched: single network message, single undo step
shapes.set(newId, clone);
layers.push(newId);
}, []);
const deleteShape = useMutation(({ storage }, shapeId: string) => {
const shapes = storage.get("shapes");
const layers = storage.get("layers");
// Remove from both structures atomically
shapes.delete(shapeId);
const layerIndex = layers
.toImmutable()
.findIndex((id) => id === shapeId);
if (layerIndex !== -1) {
layers.delete(layerIndex);
}
}, []);
// ...
}Unlike relational databases, Liveblocks storage benefits from denormalization. Store data close to where it is rendered:
// Avoid: deeply nested access requiring multiple lookups
type StorageBad = {
projects: LiveMap<string, LiveObject<{
tasks: LiveList<LiveObject<{
assignees: LiveList<LiveObject<{
userId: string;
role: string;
}>>;
}>>;
}>>;
};
// Prefer: flatter structure with ID references
type StorageGood = {
tasks: LiveMap<string, LiveObject<{
id: string;
projectId: string;
title: string;
assigneeIds: string[];
status: "todo" | "doing" | "done";
}>>;
taskOrder: LiveList<string>;
};When items have natural IDs, prefer LiveMap over LiveList:
// Use LiveMap when:
// - Items have unique IDs
// - You need O(1) lookups by ID
// - Order is tracked separately
shapes: LiveMap<string, LiveObject<ShapeData>>;
layerOrder: LiveList<string>; // Just the IDs, in order
// Use LiveList when:
// - Order is the primary concern
// - Items don't have natural IDs
// - You need index-based access
chatMessages: LiveList<LiveObject<MessageData>>;Each property in a LiveObject generates overhead. Keep your storage lean:
// Instead of storing derived data:
type ShapeBad = {
x: number;
y: number;
width: number;
height: number;
area: number; // Derived! Don't store this.
perimeter: number; // Derived! Don't store this.
};
// Store only source-of-truth data:
type ShapeGood = {
x: number;
y: number;
width: number;
height: number;
};
// Compute derived values in your component:
function ShapeInfo({ shapeId }: { shapeId: string }) {
const shape = useStorage((root) => root.shapes.get(shapeId)?.toImmutable());
if (!shape) return null;
const area = shape.width * shape.height;
const perimeter = 2 * (shape.width + shape.height);
return <p>Area: {area}, Perimeter: {perimeter}</p>;
}The fundamental guarantee of Liveblocks storage is eventual consistency: all clients will converge to the same state, regardless of network delays or operation ordering.
graph LR
A["Client A: State X"] --> |ops| M["Server merges"]
B["Client B: State Y"] --> |ops| M
M --> A2["Client A: State Z"]
M --> B2["Client B: State Z"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#fff3e0,stroke:#e65100
style A2 fill:#e8f5e9,stroke:#2e7d32
style B2 fill:#e8f5e9,stroke:#2e7d32
style M fill:#fce4ec,stroke:#c62828
| Structure | Operation | Concurrent Conflict | Resolution |
|---|---|---|---|
| LiveObject | set same property |
Two values for same key | Last-write-wins (server order) |
| LiveObject | set different properties |
No conflict | Both preserved |
| LiveList | push / insert |
Two items at same position | Both inserted, deterministic order |
| LiveList | delete same index |
Double delete | Second delete is no-op |
| LiveList | move same item |
Conflicting destinations | Last-processed move wins |
| LiveMap | set same key |
Two values for same key | Last-write-wins (server order) |
| LiveMap | set different keys |
No conflict | Both preserved |
| LiveMap | delete + set same key |
Delete vs update | Delete wins |
Storage is initialized when the first user enters a room. Subsequent users receive the existing state:
<RoomProvider
id="my-room"
initialPresence={{ cursor: null }}
initialStorage={{
document: new LiveObject({
title: "Untitled",
createdAt: new Date().toISOString(),
lastEditedBy: "",
}),
shapes: new LiveMap(),
layers: new LiveList([]),
messages: new LiveList([]),
}}
>
{children}
</RoomProvider>You can also initialize storage from a server-side API:
import { Liveblocks } from "@liveblocks/node";
const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});
// Initialize room storage via the REST API
await liveblocks.initializeStorageDocument("my-room", {
liveblocksType: "LiveObject",
data: {
document: {
liveblocksType: "LiveObject",
data: {
title: "My Document",
createdAt: new Date().toISOString(),
},
},
shapes: {
liveblocksType: "LiveMap",
data: {},
},
layers: {
liveblocksType: "LiveList",
data: [],
},
},
});In this chapter you learned:
- Three storage primitives:
LiveObjectfor structured data,LiveListfor ordered collections,LiveMapfor keyed collections - CRDT-based conflict resolution: automatic, deterministic merging of concurrent edits
- React hooks:
useStoragefor reading,useMutationfor writing, batching for atomic operations - Data modeling: denormalize, use LiveMap for collections with IDs, minimize stored data
- Convergence guarantee: all clients always converge to the same final state
- Storage is persistent -- it survives disconnections and is the source of truth for shared data.
- LiveObject resolves conflicts at the property level; different properties never conflict.
- LiveList allows concurrent insertions without conflicts using positional CRDTs.
- LiveMap is ideal for ID-keyed collections and provides O(1) lookups.
- useMutation is the only way to write to storage -- all mutations inside a single callback are batched into one operation.
- Denormalize your data -- prefer flat structures over deeply nested ones for better performance and simpler conflict resolution.
With shared data synchronized, it is time to add discussion capabilities. In Chapter 4: Comments & Threads, we will build thread-based commenting, inline annotations, and mention support.
Built with insights from the Liveblocks platform.
Most teams struggle here because the hard part is not writing more code, but deciding clear boundaries for LiveObject, fill, todos so behavior stays predictable as complexity grows.
In practical terms, this chapter helps you avoid three common failures:
- coupling core logic too tightly to one implementation path
- missing the handoff boundaries between setup, execution, and validation
- shipping changes without clear rollback or observability strategy
After working through this chapter, you should be able to reason about Chapter 3: Storage & Conflict Resolution as an operating subsystem inside Liveblocks - Real-Time Collaboration Deep Dive, with explicit contracts for inputs, state transitions, and outputs.
Use the implementation notes around shape, shapes, todo as your checklist when adapting these patterns to your own repository.
Under the hood, Chapter 3: Storage & Conflict Resolution usually follows a repeatable control path:
- Context bootstrap: initialize runtime config and prerequisites for
LiveObject. - Input normalization: shape incoming data so
fillreceives stable contracts. - Core execution: run the main logic branch and propagate intermediate state through
todos. - Policy and safety checks: enforce limits, auth scopes, and failure boundaries.
- Output composition: return canonical result payloads for downstream consumers.
- Operational telemetry: emit logs/metrics needed for debugging and performance tuning.
When debugging, walk this sequence in order and confirm each stage has explicit success/failure conditions.
Use the following upstream sources to verify implementation details while reading this chapter:
- Liveblocks GitHub Repository
Why it matters: authoritative reference on
Liveblocks GitHub Repository(github.com). - Liveblocks Product Site
Why it matters: authoritative reference on
Liveblocks Product Site(liveblocks.io). - Liveblocks Documentation
Why it matters: authoritative reference on
Liveblocks Documentation(liveblocks.io).
Suggested trace strategy:
- search upstream code for
LiveObjectandfillto map concrete implementation paths - compare docs claims against actual runtime/config code before reusing patterns in production