tldraw sync

You can add realtime multi-user collaboration to your tldraw app by using tldraw sync. It's our library for fast, fault-tolerant shared document syncing, and is used in production on our flagship app tldraw.com.

We offer a hosted demo of tldraw sync which is suitable for prototyping. To use tldraw sync in production, you will need to host it yourself.

Deploying tldraw sync

There are two main ways to go about hosting tldraw sync:

  1. Deploy a full backend to Cloudflare using our template (recommended).
  2. Integrate tldraw sync into your own JavaScript backend using our examples and docs as a guide.

Use our Cloudflare template

The best way to get started hosting your own backend is to clone and deploy our Cloudflare template. The template provides a production-grade minimal setup of the system that runs on tldraw.com.

It uses:

  • Durable Objects to provide a unique WebSocket server per room.
  • R2 to persist document snapshots and store large binary assets like images and videos.

There are some features that we have not provided and you might want to add yourself:

  • Authentication and authorization.
  • Rate limiting and size limiting for asset uploads.
  • Storing snapshots of documents over time for long-term history.
  • Listing and searching for rooms.

Make sure you also read the section below about deployment concerns.

Get started with the Cloudflare template.

Integrate tldraw sync into your own backend

We recommend using Cloudflare, but the @tldraw/sync-core library can be used to integrate tldraw sync into any JavaScript server environment that supports WebSockets.

We have a simple server example, supporting both NodeJS and Bun, to use as a reference for how things should be stitched together.

What does a tldraw sync backend do?

A backend for tldraw sync consists of two or three parts:

  • A WebSocket server that provides rooms for each shared document, and is responsible for synchronizing and persisting document state.
  • An asset storage provider for large binary files like images and videos.
  • (If using the built-in bookmark shape) An unfurling service to extract metadata about bookmark URLs.

On the frontend, there is just one part: the sync client, created using the useSync hook from the @tldraw/sync package.

Pulling all four of these together, here's what a simple client implementation might look like:

import { Tldraw, TLAssetStore, Editor } from 'tldraw'
import { useSync } from '@tldraw/sync'
import { uploadFileAndReturnUrl } from './assets'
import { convertUrlToBookmarkAsset } from './unfurl'

function MyEditorComponent({myRoomId}) {
	// This hook creates a sync client that manages the websocket connection to the server
	// and coordinates updates to the document state.
	const store = useSync({
		// This is how you tell the sync client which server and room to connect to.
		uri: `wss://my-custom-backend.com/connect/${myRoomId}`,
		// This is how you tell the sync client how to store and retrieve blobs.
		assets: myAssetStore,
	})
	// When the tldraw Editor mounts, you can register an asset handler for the bookmark URLs.
	return <Tldraw store={store} onMount={registerUrlHandler} />
}

const myAssetStore: TLAssetStore {
	upload(file, asset) {
		return uploadFileAndReturnUrl(file)
	},
	resolve(asset) {
		return asset.props.src
	},
}

function registerUrlHandler(editor: Editor) {
	editor.registerExternalAssetHandler('url', async ({url}) => {
		return await convertUrlToBookmarkAsset(url)
	})
}

And here's a full working example of the client-side code.

WebSocket server

The @tldraw/sync-core package exports a class called TLSocketRoom that should be created server-side on a per-document basis.

TLSocketRoom is used to

  • Store an authoritative in-memory copy of the document state
  • Transparently set up communication between multiple sync clients via WebSockets.
  • Provide hooks for persisting the document state when it changes.

You should make sure that there's only ever one TLSocketRoom globally for each room in your app. If there's more than one, users won't see each other and will overwrite others' changes. We use Durable Objects to achieve this on tldraw.com.

Read the reference docs for TLSocketRoom, and see an example of how to use it in the simple server example.

Storage backends

TLSocketRoom requires a storage backend to persist document state. The @tldraw/sync-core package provides two options:

InMemorySyncStorage (default)

InMemorySyncStorage keeps all document state in memory. It's simple to use but loses data when the process restarts. You'll need to implement your own persistence by listening to the onChange callback and saving snapshots to your database.

import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'

const storage = new InMemorySyncStorage({
	snapshot: existingData, // optional: load from your database
	onChange() {
		// Save to your database when changes occur
		saveToDatabase(storage.getSnapshot())
	},
})

const room = new TLSocketRoom({ storage })

SQLiteSyncStorage stores document state in SQLite, providing automatic persistence that survives process restarts. This is the recommended approach for production deployments.

For Cloudflare Durable Objects:

import { DurableObject } from 'cloudflare:workers'
import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper, TLSocketRoom } from '@tldraw/sync-core'

export class TLSyncDurableObject extends DurableObject {
	private room: TLSocketRoom

	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env)
		const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
		const storage = new SQLiteSyncStorage({ sql })
		this.room = new TLSocketRoom({ storage })
	}
}

For Node.js with better-sqlite3 or node:sqlite:

import Database from 'better-sqlite3' // replace with 'node:sqlite' if using
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'

const db = new Database('rooms.db')
const sql = new NodeSqliteWrapper(db)
const storage = new SQLiteSyncStorage({ sql })
const room = new TLSocketRoom({ storage })

SQLiteSyncStorage automatically creates and manages its database tables. You can use the tablePrefix option to avoid conflicts if you're sharing a database with other data.

Asset storage

As well as synchronizing the rapidly-changing document data, tldraw also needs a way to store and retrieve large binary assets like images or videos.

You'll need to make sure your backend can handle asset uploads & downloads, then implement TLAssetStore to connect it to tldraw.

Unfurling service

If you want to use the built-in bookmark shape, you'll need to use or implement an unfurling service that returns metadata about URLs.

This should be registered with the Editor when it loads.

<Tldraw
	store={store}
	onMount={(editor) => {
		editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
	}}
/>

Refer to the simple server example for example client and server code.

Using tldraw sync in your app

Custom shapes & bindings

@tldraw/sync validates the contents of your document and runs migrations to make sure clients of different versions can collaborate without issue. To support this, you need to make sure that both the sync client and server know about any custom shapes or bindings you've added.

On the client

You can pass shapeUtils and bindingUtils props to useSync. Unlike <Tldraw />, these don't automatically include tldraw's default shapes like arrows and rectangles. You should pass those in explicitly if you're using them:

import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
import { useSync } from '@tldraw/sync'

function MyApp() {
	const store = useSync({
		uri: '...',
		assets: myAssetStore,
		shapeUtils: useMemo(() => [...customShapeUtils, ...defaultShapeUtils], []),
		bindingUtils: useMemo(() => [...customBindingUtils, ...defaultBindingUtils], []),
	})

	return <Tldraw store={store} shapeUtils={customShapeUtils} bindingUtils={customBindingUtils} />
}

On the server

Use createTLSchema to create a store schema, and pass that into TLSocketRoom. You can use shape/binding utils here, but schema will only look at two properties: props and migrations. You need to provide the default shape schemas if you're using them.

import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
import { TLSocketRoom } from '@tldraw/sync-core'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,

		myCustomShape: {
			// Validations for this shapes `props`.
			props: myCustomShapeProps,
			// Migrations between versions of this shape.
			migrations: myCustomShapeMigrations,
		},

		// The schema knows about this shape, but it has no migrations or validation.
		mySimpleShape: {},
	},
	bindings: defaultBindingSchemas,
})

// Later, in your app server:
const room = new TLSocketRoom({
	schema: schema,
	// ...
})

Both props and migration are optional. If you omit props, you won't have any server-side validation for your shape, which could result in bad data being stored. If you omit migrations, clients on different versions won't be able to collaborate without errors.

Deployment concerns

You must make sure that the tldraw version in your client matches the version on the server. We don't guarantee server backwards compatibility forever, and very occasionally we might release a version where the backend cannot meaningfully support older clients, in which case tldraw will display a "please refresh the page" message. So you should make sure that the backend is updated at the same time as the client, and that the new backend is up and running just before the new client rolls out.

Migrating data from a legacy system

If you have been using some other solution for data sync, you can migrate your existing data to the tldraw sync format.

Both InMemorySyncStorage and SQLiteSyncStorage support loading TLStoreSnapshot snapshots, so you can add a backwards-compatibility layer that lazily imports data from your old system and converts it to a TLStoreSnapshot.

Example with SQLiteSyncStorage (recommended):

import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
import Database from 'better-sqlite3'

function loadOrMakeRoom(roomId: string, db: Database.Database) {
	const sql = new NodeSqliteWrapper(db, { tablePrefix: `room_${roomId}_` })

	// Check if we already have data in SQLite
	if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
		const storage = new SQLiteSyncStorage({ sql })
		return new TLSocketRoom({ storage })
	}

	// Try to load from legacy system
	const legacyData = loadRoomDataFromLegacyStore(roomId)
	if (legacyData) {
		const snapshot = convertOldDataToSnapshot(legacyData)
		const storage = new SQLiteSyncStorage({ sql, snapshot })
		deleteLegacyRoomData(roomId)
		return new TLSocketRoom({ storage })
	}

	// No data - create a new empty room
	return new TLSocketRoom({ storage: new SQLiteSyncStorage({ sql }) })
}

Example with InMemorySyncStorage:

import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'

async function loadOrMakeRoom(roomId: string) {
	const data = await loadRoomDataFromCurrentStore(roomId)
	if (data) {
		const storage = new InMemorySyncStorage({ snapshot: data })
		return new TLSocketRoom({ storage })
	}
	const legacyData = await loadRoomDataFromLegacyStore(roomId)
	if (legacyData) {
		// Convert your old data to a TLStoreSnapshot.
		const snapshot = convertOldDataToSnapshot(legacyData)
		// Load it into the room.
		const storage = new InMemorySyncStorage({ snapshot })
		const room = new TLSocketRoom({ storage })
		// Save an updated copy of the snapshot in the new place
		// so that next time we can load it directly.
		await saveRoomData(roomId, storage.getSnapshot())
		// Optionally delete the old data.
		await deleteLegacyRoomData(roomId)
		// And finally return the room.
		return room
	}
	// If there's no data at all, just make a new blank room.
	return new TLSocketRoom({ storage: new InMemorySyncStorage() })
}

Migrating from R2 to SQLite on Cloudflare

If you were previously using R2 to store room snapshots (as shown in earlier versions of the sync-cloudflare template), you can migrate to SQLite storage while preserving existing data.

Step 1: Update wrangler.toml if needed

If your Durable Object class was originally created without SQLite support, you need to add a new migration with a new Durable Object class that uses SQLite storage. You need to keep the old class for at least one release because it can't be deleted while it's still being used, but you can convert it to an empty class.

# Keep existing migration for old class
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]

# Add new migration for SQLite-backed class
[[migrations]]
tag = "v2"
new_sqlite_classes = ["TldrawDurableObjectSqlite"]

[durable_objects]
bindings = [
    # Point to the new SQLite-backed class
    { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObjectSqlite" },
]

Step 2: Rename your Durable Object and add fallback loading

Rename your existing class (e.g. TldrawDurableObjectTldrawDurableObjectSqlite) and update the loading logic to check SQLite first, then fall back to R2:

import {
	DurableObjectSqliteSyncWrapper,
	RoomSnapshot,
	SQLiteSyncStorage,
	TLSocketRoom,
} from '@tldraw/sync-core'
import { TLRecord, createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
import { AutoRouter, error, IRequest } from 'itty-router'

// Empty stub for the old class name - required until migration is complete
export class TldrawDurableObject {}

const schema = createTLSchema({ shapes: defaultShapeSchemas })

// Renamed from TldrawDurableObject
export class TldrawDurableObjectSqlite {
	private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
	private roomId: string | null = null

	constructor(
		private ctx: DurableObjectState,
		private env: Env
	) {
		// Load roomId from DO storage (needed for R2 fallback)
		ctx.blockConcurrencyWhile(async () => {
			this.roomId = (await ctx.storage.get('roomId')) as string | null
		})
	}

	// ... handleConnect, router, etc. stay the same ...

	private async loadRoom(): Promise<TLSocketRoom<TLRecord, void>> {
		const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)

		// If SQLite already has data, use it directly
		if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
			const storage = new SQLiteSyncStorage<TLRecord>({ sql })
			return new TLSocketRoom<TLRecord, void>({ schema, storage })
		}

		// Try to load from R2 (legacy storage, only happens once per room)
		if (this.roomId) {
			const r2Object = await this.env.TLDRAW_BUCKET.get(`rooms/${this.roomId}`)
			if (r2Object) {
				const snapshot = (await r2Object.json()) as RoomSnapshot
				const storage = new SQLiteSyncStorage<TLRecord>({ sql, snapshot })

				// Optionally delete the R2 object after successful migration
				// await this.env.TLDRAW_BUCKET.delete(`rooms/${this.roomId}`)

				return new TLSocketRoom<TLRecord, void>({ schema, storage })
			}
		}

		// No existing data - create fresh room
		const storage = new SQLiteSyncStorage<TLRecord>({ sql })
		return new TLSocketRoom<TLRecord, void>({ schema, storage })
	}
}

Step 3: Update your worker exports

Make sure both classes are exported from your worker entry point:

export { TldrawDurableObject, TldrawDurableObjectSqlite } from './TldrawDurableObject'

This approach:

  1. Checks SQLite first (fast path for already-migrated rooms)
  2. Falls back to R2 if SQLite is empty (one-time migration)
  3. Creates fresh storage for new rooms
  4. Optionally deletes R2 data after migration to save storage costs

Once all your rooms have been accessed at least once, the migration is complete. You can then remove the R2 fallback code and the stub TldrawDurableObject class.

Prev
Collaboration
Next
Starter kits