tldraw/tldraw
Multiplayer sync
Active contributors: David Sheldrick, Mitja Bezenšek, Steve Ruiz
Purpose
End-to-end story of how multiplayer works in tldraw, from a pointer move on one client to a pointer move on every other client.
The pieces
packages/sync-core/— protocol, room state, transport, persistence.packages/sync/— React hooks (useSync,useSyncDemo).apps/dotcom/sync-worker/— production server (Cloudflare Durable Objects + R2).apps/bemo-worker/— public demo server backinguseSyncDemo.templates/sync-cloudflare/,templates/simple-server-example/,templates/socketio-server-example/— starter implementations.
End-to-end flow
sequenceDiagram
participant ClientA as Client A
participant Server
participant ClientB as Client B
participant Storage
Note over ClientA: editor.updateShape(...)
ClientA->>Server: push (record diff)
Server->>Storage: persist
Server->>Server: TLSyncRoom merge
Server-->>ClientA: data (ack)
Server-->>ClientB: data (broadcast)
Note over ClientB: store.applyDiff(...)
Note over ClientB: editor re-rendersThe fast path is small (a couple of hundred bytes per pointer move), and is funneled through MicrotaskNotifier (packages/sync-core/src/lib/MicrotaskNotifier.ts) so multiple updates within the same JS turn batch into one outbound message.
Records and presence
The store is split conceptually into:
- Document records —
TLShape,TLBinding,TLAsset,TLPage,TLDocument. These are the persistent canvas content. They sync across all clients and are saved. - Session records —
TLInstance,TLInstancePageState,TLPointer,TLCamera. Local only — never go over the wire. - Presence records —
TLInstancePresence. These represent each client's cursor and selection. They sync but are ephemeral.
The schema's RecordType.scope flag (document, session, presence) drives which records are sent over the wire. See packages/store/src/lib/RecordType.ts and how TLInstancePresence is wired in packages/tlschema/src/records/TLPresence.ts.
Conflict resolution
Diffs are last-write-wins per field, mediated by the server. Each diff is tagged with the client's clock; the server merges into a canonical state and rebroadcasts the accepted result. For shape ordering, fractional indexing (packages/utils/src/lib/reordering.ts) avoids most conflicts by giving inserts unique stable keys.
Storage and tombstones
When a record is deleted, the server keeps a tombstone so late-arriving clients with stale state still learn about the deletion. Tombstones are pruned after all sessions have caught up — the algorithm is in TLSyncRoom.ts and is exercised by computeTombstonePruning.test.ts.
Production stores snapshots in R2 plus per-room sqlite inside the Durable Object. Snapshots are taken on a cadence + on session end. Restore on cold start replays from the latest snapshot.
Hooks
import { useSync } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
const store = useSync({
uri: 'wss://my-sync.example.com/connect/<roomId>',
roomId: 'my-room',
// optional: assets, userInfo, customSchema
})
return <Tldraw store={store} />useSync returns a TLStoreWithStatus that the <Tldraw /> component understands. The status atom drives the offline indicator in the SDK's UI. See packages/sync/src/lib/useSync.ts.
Self-hosting
Three template paths:
- Cloudflare Workers:
templates/sync-cloudflare/— the recommended production path. Pairsapps/bemo-worker-shaped server code withuseSyncon the client. - Plain Node:
templates/simple-server-example/— a minimal Node server. - Socket.IO:
templates/socketio-server-example/— same with Socket.IO transport.
Self-hosting means writing a TLSocketRoom host and persistence layer of your choice. The protocol is stable and versioned via protocol.ts.
Related
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.