Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
542 lines
17 KiB
Plaintext
542 lines
17 KiB
Plaintext
---
|
|
title: Collaboration
|
|
created_at: 12/26/2025
|
|
updated_at: 12/29/2025
|
|
keywords:
|
|
- multiplayer
|
|
- sync
|
|
- collaboration
|
|
- real-time
|
|
- presence
|
|
- cursors
|
|
- websocket
|
|
- custom sync
|
|
- store listeners
|
|
- RecordsDiff
|
|
status: published
|
|
date: 12/26/2025
|
|
readability: 9
|
|
voice: 8
|
|
completeness: 9
|
|
accuracy: 9
|
|
notes: Some sections could be more direct.
|
|
---
|
|
|
|
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.
|
|
|
|
Collaboration requires a server component to coordinate changes between clients. Use tldraw's demo server for prototyping, or run your own server for production.
|
|
|
|
## Quick start with the demo server
|
|
|
|
The fastest way to add multiplayer is with [useSyncDemo](?). This hook connects to a hosted demo server that handles synchronization:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
## Production setup with useSync
|
|
|
|
For production, use the [useSync](?) hook with your own server:
|
|
|
|
```tsx
|
|
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 |
|
|
|
|
### Asset storage
|
|
|
|
Production setups require a [TLAssetStore](?) for handling images, videos, and other files:
|
|
|
|
```tsx
|
|
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](/docs/assets) documentation for more on implementing asset stores.
|
|
|
|
## User identity
|
|
|
|
By default, users get a default name and a random color, stored in localStorage. To customize this, pass a [TLUserStore](?) as `users`, whose methods return reactive `Signal` values:
|
|
|
|
```tsx
|
|
import { computed, UserRecordType, createUserId } from 'tldraw'
|
|
|
|
const currentUser = computed('currentUser', () =>
|
|
UserRecordType.create({
|
|
id: createUserId('user-123'),
|
|
name: 'Alice',
|
|
color: '#ff0000',
|
|
})
|
|
)
|
|
|
|
const store = useSyncDemo({
|
|
roomId: 'my-room',
|
|
users: {
|
|
currentUser,
|
|
},
|
|
})
|
|
```
|
|
|
|
The `users` store is also used for shape attribution — the same `currentUser` signal stamps shapes with user IDs. See the [Attribution](/sdk-features/attribution) page for details on tracking which users create or edit shapes. Provide a `resolve` method to look up other users by ID:
|
|
|
|
```tsx
|
|
const users: TLUserStore = {
|
|
currentUser: myAuth.currentUser$,
|
|
resolve: (userId) => myUserCache.getSignal(userId),
|
|
}
|
|
```
|
|
|
|
### Integrating with useTldrawCurrentUser
|
|
|
|
If you need to let users edit their preferences through tldraw's UI, use [useTldrawCurrentUser](?):
|
|
|
|
```tsx
|
|
import { useSyncDemo } from '@tldraw/sync'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
atom,
|
|
computed,
|
|
createUserId,
|
|
TLUserPreferences,
|
|
TLUserStore,
|
|
Tldraw,
|
|
UserRecordType,
|
|
useTldrawCurrentUser,
|
|
} from 'tldraw'
|
|
|
|
export default function App({ roomId }: { roomId: string }) {
|
|
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
|
|
id: 'user-123',
|
|
name: 'Alice',
|
|
color: 'coral',
|
|
colorScheme: 'dark',
|
|
})
|
|
|
|
const userPrefsAtom = useRef(atom('userPrefs', userPreferences)).current
|
|
useEffect(() => {
|
|
userPrefsAtom.set(userPreferences)
|
|
}, [userPreferences, userPrefsAtom])
|
|
|
|
const users: TLUserStore = useMemo(() => {
|
|
const currentUser = computed('currentUser', () => {
|
|
const p = userPrefsAtom.get()
|
|
return UserRecordType.create({
|
|
id: createUserId(p.id),
|
|
name: p.name ?? '',
|
|
color: p.color ?? '',
|
|
})
|
|
})
|
|
return { currentUser }
|
|
}, [userPrefsAtom])
|
|
|
|
const store = useSyncDemo({ roomId, users })
|
|
const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })
|
|
|
|
return (
|
|
<div style={{ position: 'fixed', inset: 0 }}>
|
|
<Tldraw store={store} user={user} />
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Connection status
|
|
|
|
When using `useSync`, the store object includes connection status information:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
## Custom presence
|
|
|
|
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`, starting from [getDefaultUserPresence](?):
|
|
|
|
```tsx
|
|
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.
|
|
|
|
## Authentication
|
|
|
|
To add authentication, generate the WebSocket URI dynamically:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
## Running your own server
|
|
|
|
For production, you'll need to run a sync server. The `@tldraw/sync-core` package provides [TLSocketRoom](?) for server-side room management.
|
|
|
|
Our [Cloudflare Workers template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare) is the same setup that runs tldraw.com. It includes:
|
|
|
|
- WebSocket sync via Durable Objects (one per room)
|
|
- Asset storage with R2
|
|
- Bookmark unfurling for URL previews
|
|
|
|
Get started with the template:
|
|
|
|
```bash
|
|
npm create tldraw@latest -- --template multiplayer
|
|
```
|
|
|
|
Or copy the relevant pieces into your existing infrastructure. See [Sync](/docs/sync) for other server setups.
|
|
|
|
### Server architecture
|
|
|
|
The sync server uses a room-based model:
|
|
|
|
1. Each document has a unique room ID
|
|
2. Clients connect via WebSocket to their room
|
|
3. The server maintains one `TLSocketRoom` per active room
|
|
4. Changes broadcast to all connected clients in real-time
|
|
5. The server is authoritative for conflict resolution
|
|
|
|
```
|
|
┌─────────┐ ┌─────────────────┐ ┌─────────┐
|
|
│ Client │────▶│ TLSocketRoom │◀────│ Client │
|
|
└─────────┘ │ (per room) │ └─────────┘
|
|
└────────┬────────┘
|
|
│
|
|
┌────▼────┐
|
|
│ Storage │
|
|
└─────────┘
|
|
```
|
|
|
|
## Custom shapes and bindings
|
|
|
|
If you use custom shapes or bindings, register them with the sync hooks using schema options:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
`useSyncDemo` adds tldraw's default utilities for you. `useSync` doesn't: when you pass `shapeUtils` or `bindingUtils` to `useSync`, include the defaults yourself, for example `shapeUtils: [...defaultShapeUtils, MyCustomShapeUtil]`.
|
|
|
|
## Comments
|
|
|
|
Comment threads sync the same way, through record types you opt into with the `records` option:
|
|
|
|
```tsx
|
|
import { useSync } from '@tldraw/sync'
|
|
import { commentSchemaRecords } from 'tldraw'
|
|
|
|
const store = useSync({
|
|
uri: `wss://your-server.com/sync/${roomId}`,
|
|
assets: myAssetStore,
|
|
records: commentSchemaRecords,
|
|
})
|
|
```
|
|
|
|
Your server's schema needs the same registration. On the server you can also serve comments through the room's object-store lane, which gates them by their own per-session permission. A viewer who can't edit the document can still comment. See [Commenting](/sdk-features/commenting) for the tool, the canvas layer, and the server setup.
|
|
|
|
## Building custom sync
|
|
|
|
The `@tldraw/sync` package handles connection management, reconnection, conflict resolution, and protocol versioning. For most applications, it's the right choice. 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.
|
|
|
|
### Listening to changes
|
|
|
|
The store's [Store#listen](?) method notifies you when records change:
|
|
|
|
```tsx
|
|
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).
|
|
|
|
### Change structure
|
|
|
|
Changes arrive as a [RecordsDiff](?) object with three categories:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
### Applying remote changes
|
|
|
|
When you receive changes from other clients, wrap them in [Store#mergeRemoteChanges](?):
|
|
|
|
```tsx
|
|
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 transaction, and remote changes never enter the undo/redo history.
|
|
|
|
### Snapshots and serialization
|
|
|
|
For initial sync or persistence, serialize the entire store:
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
### Presence records
|
|
|
|
User presence (cursors, selections, viewports) uses [InstancePresenceRecordType](?) records. Only `userId`, `userName`, and `currentPageId` are required; everything else has a default:
|
|
|
|
```tsx
|
|
import { createUserId, InstancePresenceRecordType } from 'tldraw'
|
|
|
|
// Create a presence record for a remote user, keyed by that client's session id
|
|
const presence = InstancePresenceRecordType.create({
|
|
id: InstancePresenceRecordType.createId(remoteSessionId),
|
|
userId: createUserId('user-123'),
|
|
userName: 'Alice',
|
|
color: '#ff6b6b',
|
|
currentPageId: editor.getCurrentPageId(),
|
|
cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
|
|
lastActivityTimestamp: Date.now(),
|
|
})
|
|
|
|
// Add to store
|
|
editor.store.put([presence])
|
|
|
|
// Update cursor position
|
|
editor.store.update(presence.id, (record) => ({
|
|
...record,
|
|
cursor: { x: 150, y: 250, type: 'default', rotation: 0 },
|
|
}))
|
|
|
|
// Remove when user disconnects
|
|
editor.store.remove([presence.id])
|
|
```
|
|
|
|
Listen for presence changes separately from document changes:
|
|
|
|
```tsx
|
|
editor.store.listen(
|
|
(entry) => {
|
|
// Broadcast presence to other clients
|
|
sendPresence(entry.changes)
|
|
},
|
|
{ source: 'user', scope: 'presence' }
|
|
)
|
|
```
|
|
|
|
### Example: simple broadcast sync
|
|
|
|
Here's a minimal example using a WebSocket for broadcast sync (no conflict resolution):
|
|
|
|
```tsx
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Tldraw, createTLStore, defaultBindingUtils, defaultShapeUtils } from 'tldraw'
|
|
|
|
function App() {
|
|
const [store] = useState(() =>
|
|
createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils })
|
|
)
|
|
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 initial state sync, reconnection handling, and conflict resolution. For production, start with `@tldraw/sync` and customize it, or study its implementation for how it handles these cases.
|
|
|
|
## Related examples
|
|
|
|
- [Multiplayer sync](/examples/collaboration/sync-demo) — Basic multiplayer setup with the demo server
|
|
- [Custom user](/examples/users/sync-custom-user) — Setting custom user identity for multiplayer
|
|
- [Custom presence](/examples/collaboration/sync-custom-presence) — Customizing presence data sent to collaborators
|
|
- [Custom shapes](/examples/collaboration/sync-custom-shape) — Syncing custom shapes with multiplayer
|
|
- [User presence](/examples/collaboration/user-presence) — Manually creating and updating presence records
|