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:
- Deploy a full backend to Cloudflare using our template (recommended).
- 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 (recommended for persistence)
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.
- Read about how assets work in tldraw.
- Read the
TLAssetStorereference docs. - See a complete example of an asset store in the
tldraw-sync-cloudflaretemplate.
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. TldrawDurableObject → TldrawDurableObjectSqlite) 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:
- Checks SQLite first (fast path for already-migrated rooms)
- Falls back to R2 if SQLite is empty (one-time migration)
- Creates fresh storage for new rooms
- 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.