| layout | default |
|---|---|
| title | Chapter 6: Collaboration and Sync |
| nav_order | 6 |
| parent | tldraw Tutorial |
Welcome to Chapter 6: Collaboration and Sync. In this part of tldraw Tutorial, you will learn how tldraw's Store supports real-time multiplayer collaboration and how to implement different sync strategies for your application.
In Chapter 5, you used the canvas for AI-powered generation. Now you will make the canvas collaborative — multiple users drawing, selecting, and editing shapes simultaneously.
Real-time collaboration on a shared canvas is a deceptively hard problem. Multiple users can create, move, and delete shapes concurrently, leading to conflicts. Cursor positions must be shared with low latency. Undo/redo must be scoped to each user. The tldraw Store provides a change-tracking system that makes it possible to build collaborative experiences on top of any transport layer — WebSockets, WebRTC, or even a shared database.
- understand the Store's change tracking and diff system
- implement a basic WebSocket sync server for multiplayer
- handle presence data — cursors, selections, and user names
- manage conflicts and operational ordering
- use the
TLSocketRoomclass from tldraw's sync package
The Store emits fine-grained change records whenever data is modified:
flowchart LR
A[User creates shape] --> B[Store.put record]
B --> C[Store emits change event]
C --> D[Sync layer captures diff]
D --> E[Send diff to server]
E --> F[Server broadcasts to other clients]
F --> G[Other clients apply diff]
import { createTLStore, defaultShapeUtils } from 'tldraw'
const store = createTLStore({ shapeUtils: defaultShapeUtils })
// Listen for all changes
store.listen((entry) => {
// entry.changes contains:
// added: Record<string, TLRecord> — new records
// updated: Record<string, [before, after]> — changed records
// removed: Record<string, TLRecord> — deleted records
const { added, updated, removed } = entry.changes
console.log('Added:', Object.keys(added).length)
console.log('Updated:', Object.keys(updated).length)
console.log('Removed:', Object.keys(removed).length)
// entry.source is 'user' for local changes, 'remote' for applied diffs
if (entry.source === 'user') {
// Send this diff to the server
sendToServer(entry.changes)
}
}, { source: 'all', scope: 'document' })The Store supports full snapshots and incremental diffs:
// Get a full snapshot of all records
const snapshot = store.getStoreSnapshot()
// snapshot contains all shape, page, asset records
// Load a snapshot (replaces all data)
store.loadStoreSnapshot(snapshot)
// Apply a diff from another client
store.mergeRemoteChanges(() => {
// Inside this callback, changes are marked as 'remote'
// and do not trigger the listener with source: 'user'
for (const record of Object.values(diff.added)) {
store.put([record])
}
for (const [_before, after] of Object.values(diff.updated)) {
store.put([after])
}
for (const record of Object.values(diff.removed)) {
store.remove([record.id])
}
})Here is a minimal sync implementation with a WebSocket server:
// server.ts
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
// In-memory document state
let documentRecords: Record<string, any> = {}
const clients = new Set<any>()
wss.on('connection', (ws) => {
clients.add(ws)
// Send current state to the new client
ws.send(JSON.stringify({
type: 'init',
snapshot: documentRecords,
}))
ws.on('message', (data) => {
const message = JSON.parse(data.toString())
if (message.type === 'diff') {
// Apply diff to server state
const { added, updated, removed } = message.changes
for (const [id, record] of Object.entries(added)) {
documentRecords[id] = record
}
for (const [id, [_before, after]] of Object.entries(updated as any)) {
documentRecords[id] = after
}
for (const id of Object.keys(removed)) {
delete documentRecords[id]
}
// Broadcast to all other clients
for (const client of clients) {
if (client !== ws && client.readyState === 1) {
client.send(JSON.stringify({
type: 'diff',
changes: message.changes,
}))
}
}
}
})
ws.on('close', () => {
clients.delete(ws)
})
})// src/useSync.ts
import { useEffect } from 'react'
import { Editor } from 'tldraw'
export function useSync(editor: Editor, roomId: string) {
useEffect(() => {
const ws = new WebSocket(`ws://localhost:8080?room=${roomId}`)
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'init') {
// Load initial state
editor.store.loadStoreSnapshot(message.snapshot)
}
if (message.type === 'diff') {
// Apply remote changes
editor.store.mergeRemoteChanges(() => {
const { added, updated, removed } = message.changes
const recordsToAdd = [
...Object.values(added),
...Object.values(updated).map(([_, after]: any) => after),
]
if (recordsToAdd.length > 0) {
editor.store.put(recordsToAdd as any[])
}
const idsToRemove = Object.keys(removed)
if (idsToRemove.length > 0) {
editor.store.remove(idsToRemove as any[])
}
})
}
}
// Send local changes to the server
const unlisten = editor.store.listen((entry) => {
if (entry.source === 'user') {
ws.send(JSON.stringify({
type: 'diff',
changes: entry.changes,
}))
}
}, { source: 'user', scope: 'document' })
return () => {
unlisten()
ws.close()
}
}, [editor, roomId])
}Multiplayer canvases need to show where other users are and what they are doing:
flowchart TD
subgraph PresenceData["Presence data per user"]
Cursor[Cursor position]
Selection[Selected shape IDs]
UserInfo[Name, color, avatar]
ViewportBounds[Viewport bounds]
end
PresenceData --> Broadcast[Broadcast via WebSocket]
Broadcast --> OtherClients[Other clients render indicators]
// The Store has a special 'presence' scope for ephemeral data
// Instance-level records (cursor position, selected tool, etc.)
// are scoped to the instance and can be shared as presence
// Listen for presence changes specifically
editor.store.listen((entry) => {
if (entry.source === 'user') {
// Send presence data: cursor position, selection, viewport
const presence = {
cursor: editor.inputs.currentPagePoint,
selectedIds: editor.getSelectedShapeIds(),
userName: 'Alice',
color: '#3b82f6',
}
ws.send(JSON.stringify({ type: 'presence', data: presence }))
}
}, { source: 'user', scope: 'session' })
// Render other users' cursors as a React component
function CollaboratorCursors({ collaborators }: { collaborators: any[] }) {
return (
<>
{collaborators.map((c) => (
<div
key={c.id}
style={{
position: 'absolute',
left: c.cursor.x,
top: c.cursor.y,
pointerEvents: 'none',
transform: 'translate(-2px, -2px)',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M0 0 L0 14 L4 10 L8 14 L10 12 L6 8 L12 8 Z" fill={c.color} />
</svg>
<span
style={{
background: c.color,
color: 'white',
padding: '2px 6px',
borderRadius: 4,
fontSize: 11,
whiteSpace: 'nowrap',
}}
>
{c.userName}
</span>
</div>
))}
</>
)
}tldraw provides a @tldraw/sync package with production-ready sync infrastructure:
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// The simplest way to get multiplayer — tldraw's demo sync server
function App() {
const store = useSyncDemo({ roomId: 'my-room-123' })
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store} />
</div>
)
}For production use with your own server, use useSync:
import { useSync } from '@tldraw/sync'
import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
import 'tldraw/tldraw.css'
function App() {
const store = useSync({
uri: `wss://your-sync-server.com/connect/my-room`,
shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
})
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store} />
</div>
)
}The tldraw sync model uses last-writer-wins (LWW) semantics at the record level:
sequenceDiagram
participant A as Client A
participant S as Server
participant B as Client B
A->>S: Update shape.color = "red" (clock: 5)
B->>S: Update shape.color = "blue" (clock: 6)
S->>S: clock 6 > clock 5, keep "blue"
S->>A: shape.color = "blue"
S->>B: shape.color = "blue" (confirmed)
This is simpler than CRDT-based approaches (as used in AFFiNE) but sufficient for canvas use cases where:
- shapes are typically edited by one user at a time
- concurrent edits to the same property are rare
- the visual nature makes conflicts immediately visible
The @tldraw/sync package implements a room-based sync protocol:
- Connect — client sends a
connectmessage with its last known clock value - Init — server responds with all records newer than the client's clock
- Push — client sends diffs to the server as the user makes changes
- Patch — server broadcasts diffs to all other clients in the room
- Presence — ephemeral data (cursors, selections) flows through a separate channel with no persistence
The server can be backed by any storage — SQLite for small deployments, PostgreSQL for production, or a key-value store like Redis for high-throughput scenarios.
The Store's change tracking system makes it straightforward to build collaborative tldraw applications. You can use the built-in @tldraw/sync package for turnkey multiplayer or build custom sync on top of the Store's listener API. Presence data flows through a separate ephemeral channel for cursor and selection sharing. In the next chapter, you will learn how to embed tldraw into production applications.
Previous: Chapter 5: AI Make-Real Feature | Next: Chapter 7: Embedding and Integration