Open-Source Wikis

/

tldraw

/

Features

/

Multiplayer sync

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 backing useSyncDemo.
  • 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-renders

The 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 recordsTLShape, TLBinding, TLAsset, TLPage, TLDocument. These are the persistent canvas content. They sync across all clients and are saved.
  • Session recordsTLInstance, TLInstancePageState, TLPointer, TLCamera. Local only — never go over the wire.
  • Presence recordsTLInstancePresence. 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. Pairs apps/bemo-worker-shaped server code with useSync on 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.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Multiplayer sync – tldraw wiki | Factory