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 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. 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