apps/docs/content/sdk-features/collaboration.mdx
The @tldraw/sync package provides real-time multiplayer collaboration for tldraw. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports. The sync system handles connection management, conflict resolution, and presence synchronization automatically.
Collaboration requires a server component to coordinate changes between clients. Use tldraw's demo server for prototyping, or run your own server for production.
The fastest way to add multiplayer is with useSyncDemo. This hook connects to a hosted demo server that handles synchronization:
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
const store = useSyncDemo({ roomId: 'my-room-id' })
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store} />
</div>
)
}
Anyone who opens the app with the same roomId will see the same document and each other's cursors. The demo server is great for prototyping, but data is deleted after a day and rooms are publicly accessible by ID. Don't use it in production.
For production, use the useSync hook with your own server:
import { useSync } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function Room({ roomId }: { roomId: string }) {
const store = useSync({
uri: `wss://your-server.com/sync/${roomId}`,
assets: myAssetStore,
})
if (store.status === 'loading') {
return <div>Connecting...</div>
}
if (store.status === 'error') {
return <div>Connection error: {store.error.message}</div>
}
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store.store} />
</div>
)
}
The useSync hook returns a RemoteTLStoreWithStatus object with three possible states:
| Status | Description |
|---|---|
loading | Establishing connection and performing initial sync |
synced-remote | Connected and syncing. Includes store and connectionStatus |
error | Connection failed. Includes error with details |
Production setups require an asset store for handling images, videos, and other files:
const myAssetStore: TLAssetStore = {
upload: async (asset, file) => {
const response = await fetch('/api/upload', {
method: 'POST',
body: file,
})
const { url } = await response.json()
return { src: url }
},
resolve: (asset, context) => {
// context includes dpr, networkEffectiveType, and shouldResolveToOriginal
return asset.props.src
},
}
const store = useSync({
uri: `wss://your-server.com/sync/${roomId}`,
assets: myAssetStore,
})
See the Assets documentation for more on implementing asset stores.
By default, users get a random name and color. To customize this, pass userInfo:
const store = useSyncDemo({
roomId: 'my-room',
userInfo: {
id: 'user-123',
name: 'Alice',
color: '#ff0000',
},
})
For dynamic user info that updates during the session, use an atom:
import { atom } from 'tldraw'
const userInfo = atom('userInfo', {
id: 'user-123',
name: 'Alice',
color: '#ff0000',
})
// Later, update the user info
userInfo.set({ ...userInfo.get(), name: 'Alice (away)' })
const store = useSyncDemo({
roomId: 'my-room',
userInfo,
})
If you need to let users edit their preferences through tldraw's UI, use useTldrawUser:
import { useSyncDemo } from '@tldraw/sync'
import { useState } from 'react'
import { TLUserPreferences, Tldraw, useTldrawUser } from 'tldraw'
export default function App({ roomId }: { roomId: string }) {
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
id: 'user-123',
name: 'Alice',
color: 'coral',
colorScheme: 'dark',
})
const store = useSyncDemo({ roomId, userInfo: userPreferences })
const user = useTldrawUser({ userPreferences, setUserPreferences })
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store} user={user} />
</div>
)
}
When using useSync, the store object includes connection status information:
const store = useSync({ uri, assets })
if (store.status === 'synced-remote') {
// store.connectionStatus is 'online' or 'offline'
console.log('Connection:', store.connectionStatus)
}
The connection status reflects the WebSocket connection state. When offline, changes are queued locally and sync when the connection resumes.
The presence system controls what information is shared with other users. By default, it includes cursor position, selected shapes, and viewport bounds. You can customize this with getUserPresence:
import { getDefaultUserPresence } from 'tldraw'
const store = useSyncDemo({
roomId: 'my-room',
getUserPresence(store, user) {
const defaults = getDefaultUserPresence(store, user)
if (!defaults) return null
return {
...defaults,
// Remove camera/viewport to disable follow functionality
camera: undefined,
}
},
})
Return null from getUserPresence to hide this user's presence entirely. This is useful for spectator modes where you want a user to observe without appearing in the room.
To add authentication, generate the WebSocket URI dynamically:
const store = useSync({
uri: async () => {
const token = await getAuthToken()
return `wss://your-server.com/sync/${roomId}?token=${token}`
},
assets: myAssetStore,
})
The uri option accepts a function that returns a string or Promise. This runs when establishing the connection and on reconnection, so tokens can refresh automatically.
For production, you'll need to run a sync server. The @tldraw/sync-core package provides TLSocketRoom for server-side room management.
We provide a complete Cloudflare Workers template that includes:
Get started with the template:
npx create-tldraw@latest --template sync-cloudflare
Or copy the relevant pieces to your existing infrastructure. The template handles the complexity of room lifecycle, connection management, and state persistence.
The sync server uses a room-based model:
TLSocketRoom per active room┌─────────┐ ┌─────────────────┐ ┌─────────┐
│ Client │────▶│ TLSocketRoom │◀────│ Client │
└─────────┘ │ (per room) │ └─────────┘
└────────┬────────┘
│
┌────▼────┐
│ Storage │
└─────────┘
If you use custom shapes or bindings, register them with the sync hooks using schema options:
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import { MyCustomShapeUtil } from './MyCustomShape'
import { MyCustomBindingUtil } from './MyCustomBinding'
const customShapes = [MyCustomShapeUtil]
const customBindings = [MyCustomBindingUtil]
export default function App({ roomId }: { roomId: string }) {
const store = useSyncDemo({
roomId,
shapeUtils: customShapes,
bindingUtils: customBindings,
})
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw store={store} shapeUtils={customShapes} bindingUtils={customBindings} />
</div>
)
}
Pass the shape and binding utilities to both the sync hook (for schema registration) and the Tldraw component (for rendering). If they don't match, shapes may fail to sync or render correctly.
The @tldraw/sync package handles many complexities: connection management, reconnection, conflict resolution, and protocol versioning. For most applications, it's the right choice. However, you might need custom sync when integrating with existing infrastructure, using a different transport (like WebRTC), or implementing specialized conflict resolution.
tldraw's store provides the primitives you need to build your own sync layer.
The store's listen method notifies you when records change:
const unsubscribe = editor.store.listen(
(entry) => {
// entry.changes contains all modifications
// entry.source is 'user' (local) or 'remote'
console.log('Changes:', entry.changes)
},
{ source: 'user', scope: 'document' }
)
// Later: stop listening
unsubscribe()
Filter options narrow what changes you receive:
| Filter | Values | Description |
|---|---|---|
source | 'user', 'remote', 'all' | Who made the change |
scope | 'document', 'session', 'presence', 'all' | What type of data changed |
For sync, you typically want source: 'user' (only local changes) and scope: 'document' (only persistent data).
Changes arrive as a RecordsDiff object with three categories:
interface RecordsDiff<R> {
added: Record<string, R> // New records
updated: Record<string, [from: R, to: R]> // Changed records (before/after)
removed: Record<string, R> // Deleted records
}
Each entry is keyed by record ID. For updates, you get both the previous and current state, which is useful for conflict detection or generating patches.
When you receive changes from other clients, wrap them in mergeRemoteChanges:
function applyRemoteChanges(records: TLRecord[], deletedIds: TLRecord['id'][]) {
editor.store.mergeRemoteChanges(() => {
if (records.length > 0) {
editor.store.put(records)
}
if (deletedIds.length > 0) {
editor.store.remove(deletedIds)
}
})
}
This marks the changes as 'remote' source, so your own listener won't echo them back to the server. It also batches the operations into a single history entry.
For initial sync or persistence, serialize the entire store:
// Get all document records as a plain object
const data = editor.store.serialize('document')
// Returns: { 'shape:abc': {...}, 'page:xyz': {...}, ... }
// Get a snapshot with schema information (recommended for persistence)
const snapshot = editor.store.getStoreSnapshot('document')
// Returns: { store: {...}, schema: {...} }
// Restore from snapshot (handles migrations automatically)
editor.store.loadStoreSnapshot(snapshot)
The snapshot format includes schema information, so tldraw can automatically migrate old data when your schema evolves.
User presence (cursors, selections, viewports) uses special instance_presence records:
import { InstancePresenceRecordType } from 'tldraw'
// Create a presence record for a remote user
const presence = InstancePresenceRecordType.create({
id: InstancePresenceRecordType.createId(
editor.store.id // Store ID identifies this client
),
userId: 'user-123',
userName: 'Alice',
color: '#ff6b6b',
currentPageId: editor.getCurrentPageId(),
cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
selectedShapeIds: [],
camera: { x: 0, y: 0, z: 1 },
screenBounds: { x: 0, y: 0, w: 1920, h: 1080 },
lastActivityTimestamp: Date.now(),
chatMessage: '',
brush: null,
scribbles: [],
followingUserId: null,
meta: {},
})
// Add to store
editor.store.put([presence])
// Update cursor position
editor.store.update(presence.id, (record) => ({
...record,
cursor: { ...record.cursor, x: 150, y: 250 },
}))
// Remove when user disconnects
editor.store.remove([presence.id])
Listen for presence changes separately from document changes:
editor.store.listen(
(entry) => {
// Broadcast presence to other clients
sendPresence(entry.changes)
},
{ source: 'user', scope: 'presence' }
)
Here's a minimal example using a WebSocket for broadcast sync (no conflict resolution):
import { Tldraw, createTLStore, defaultShapeUtils, TLRecord } from 'tldraw'
function App() {
const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils }))
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
const ws = new WebSocket('wss://your-server.com/room/123')
wsRef.current = ws
// Send local changes to server
const unsubscribe = store.listen(
(entry) => {
ws.send(
JSON.stringify({
type: 'changes',
added: Object.values(entry.changes.added),
updated: Object.values(entry.changes.updated).map(([, to]) => to),
removed: Object.keys(entry.changes.removed),
})
)
},
{ source: 'user', scope: 'document' }
)
// Apply remote changes
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'changes') {
store.mergeRemoteChanges(() => {
if (msg.added.length || msg.updated.length) {
store.put([...msg.added, ...msg.updated])
}
if (msg.removed.length) {
store.remove(msg.removed)
}
})
}
}
return () => {
unsubscribe()
ws.close()
}
}, [store])
return <Tldraw store={store} />
}
This example omits important concerns like initial state sync, reconnection handling, and conflict resolution. For production use, consider starting with @tldraw/sync and customizing it, or studying its implementation for guidance on handling these edge cases.