Back to Tldraw

Performance

apps/docs/content/sdk-features/performance.mdx

5.4.011.8 KB
Original Source

The tldraw SDK uses several techniques to maintain smooth performance even with thousands of shapes on the canvas. This article covers what the SDK does for you and what to do in custom shapes.

How tldraw optimizes rendering

Viewport culling

Shapes outside the viewport don't need to render. The editor maintains a spatial index that tracks which shapes are visible, and hides off-screen shapes by setting display: none on their DOM elements. This means a canvas with 10,000 shapes might only render 50 if the rest are out of view.

Culling happens automatically for all shapes. The shapes remain in the store and can still be selected or updated; they just don't incur rendering cost. Selected shapes and the shape being edited are never culled. See Culling for details on how to control this behavior for custom shapes.

Reactive signals

The SDK uses reactive signals instead of React's built-in state management. Signals automatically track dependencies and update only the parts of your application that actually depend on changed data.

When a shape's props change, only that shape's component re-renders—not the entire canvas. The system tracks dependencies at a granular level, so changing a shape's color won't trigger updates for shapes that don't care about color.

This is why methods like editor.getSelectedShapeIds() return reactive values. If you access them inside a track() component or useValue() hook, your code automatically re-runs when the underlying data changes.

Batched store updates

The store batches multiple changes into single updates. When you call methods like editor.createShapes() or editor.updateShapes() with multiple shapes, observers receive one notification with all changes rather than one per shape:

ts
// These changes are batched automatically
editor.updateShapes([
	{ id: shape1.id, type: 'geo', x: 100 },
	{ id: shape2.id, type: 'geo', x: 200 },
	{ id: shape3.id, type: 'geo', x: 300 },
])

For complex operations spanning multiple calls, wrap them in editor.run():

ts
editor.run(() => {
	editor.createShapes([...])
	editor.updateShapes([...])
	editor.deleteShapes([...])
})
// All changes applied together, listeners notified once

Debounced zoom

When the camera moves, shape components receive the new zoom level to scale stroke widths and other visual properties. On documents with many shapes, recalculating everything mid-zoom causes jank.

The editor provides Editor#getEfficientZoomLevel, which returns a stable value during camera movement when the document has more than 500 shapes (configurable via the debouncedZoomThreshold option). Once the camera stops, the value updates to the true zoom level.

Shape components should use this value rather than editor.getZoomLevel() for properties that affect rendering:

tsx
function MyShapeComponent({ shape }: { shape: MyShape }) {
	const editor = useEditor()
	const zoom = useValue('zoom', () => editor.getEfficientZoomLevel(), [editor])

	// Stroke width stays stable during camera movement
	const strokeWidth = 2 / zoom

	return <path d={getPathForShape(shape)} strokeWidth={strokeWidth} />
}

Geometry caching

Computing a shape's geometry (bounds, hit test regions, outline) can be expensive. The editor caches these computations and invalidates them only when a shape's props change.

Access cached geometry through Editor#getShapeGeometry rather than calling shapeUtil.getGeometry() directly. The editor handles caching, transforms, and bounds calculation.

Level of detail

tldraw adjusts rendering fidelity based on zoom level and on-screen size, a technique called level of detail (LOD). When a shape is small on screen, rendering every pixel of a high-resolution image or every detail of a complex shape is wasted work.

Image resolution scaling

When you zoom out or resize an image shape, tldraw requests a lower-resolution version from your asset store. The resolve method on TLAssetStore receives a TLAssetContext with steppedScreenScale: the ratio of the shape's on-screen size (in CSS pixels) to the image's native size, rounded up to the nearest power of two. Multiply by dpr to get device pixels:

ts
const assetStore: TLAssetStore = {
	async resolve(asset, context) {
		if (asset.type !== 'image') return asset.props.src

		// Request a version of the image scaled to what's actually visible on screen
		const width = Math.ceil(asset.props.w * context.steppedScreenScale * context.dpr)
		return `${asset.props.src}?w=${width}`
	},
}

A 4000px-wide photo zoomed out to take up 200px on screen has a screen scale of 0.05, which steps up to 0.0625, so you'd serve a 250px-wide image (times dpr) instead of the full 4000px. This reduces memory usage and decoding cost. Resolution updates are debounced so images don't thrash between sizes during zooming.

Built-in shape simplifications

The built-in shapes reduce rendering complexity at low zoom levels. Sticky notes drop their box shadow in favor of a plain bottom border. Dashed and dotted freehand strokes render as solid lines. The hatch pattern fill switches to a solid fallback color. Text outlines turn off below the textShadowLod threshold (default 0.35) to reduce compositing cost, and are always off on Safari.

These transitions use Editor#getEfficientZoomLevel so they stay stable during camera movement rather than updating every frame. Custom shapes can use the same technique. See Simplify at small sizes below.

Tips for custom shapes

Simplify at small sizes

When shapes are very small on screen, fine details become invisible. Rendering simpler geometry at low zoom levels improves performance without visible quality loss.

Use editor.getEfficientZoomLevel() to detect when shapes are small enough to simplify:

tsx
function MyShapeComponent({ shape }: { shape: MyShape }) {
	const editor = useEditor()
	const isSmall = useValue(
		'is small',
		() => {
			const zoom = editor.getEfficientZoomLevel()
			// Shape is small if its screen size is under 50px
			return shape.props.w * zoom < 50
		},
		[editor, shape.props.w]
	)

	if (isSmall) {
		// Render simplified version
		return <rect width={shape.props.w} height={shape.props.h} fill="currentColor" />
	}

	// Render full detail version
	return <ComplexShapeContent shape={shape} />
}

Avoid shape animations

Animating shape properties causes continuous re-renders. A spinning shape triggers updates every frame. If you have many shapes or complex rendering, this adds up quickly.

If you need animation, use CSS animations for purely visual effects that don't change shape data, use a canvas for particle systems or complex effects, and keep the number of concurrently animating shapes small.

The Animation article covers the editor's animation system. It handles camera movement and occasional shape transitions. It's not designed for continuous per-shape animation.

Keep component functions cheap

Shape components render frequently. Avoid expensive operations inside them:

tsx
// Avoid: expensive calculation every render
function MyShapeComponent({ shape }) {
	const complexData = computeExpensiveData(shape) // runs every render
	return <div>{complexData}</div>
}

// Better: use memoization or move to getGeometry
function MyShapeComponent({ shape }) {
	const complexData = useMemo(() => computeExpensiveData(shape), [shape.props.relevantProp])
	return <div>{complexData}</div>
}

For calculations that affect hit testing or bounds, put them in getGeometry() instead. The editor caches geometry automatically.

Disable culling only when necessary

By default, all shapes participate in culling. Override ShapeUtil#canCull to return false only for shapes that genuinely need to stay rendered off-screen:

ts
class MyShapeUtil extends ShapeUtil<MyShape> {
	override canCull(shape: MyShape): boolean {
		// Only disable culling for shapes that measure their DOM
		return !shape.props.dynamicSize
	}
}

Reasons to disable culling include shapes that measure their DOM content to determine size, shapes with visual effects (shadows, glows) that extend beyond their bounds, and shapes running animations that should continue off-screen. For most shapes, leave culling enabled.

Editor options for performance

Several editor options affect performance:

OptionDefaultDescription
debouncedZoomtrueUse stable zoom during camera movement
debouncedZoomThreshold500Shape count above which debounced zoom activates
maxShapesPerPage4000Maximum shapes allowed per page
textShadowLod0.35Zoom threshold below which text shadows disable
tsx
import { Tldraw } from 'tldraw'

function App() {
	return (
		<Tldraw
			options={{
				debouncedZoomThreshold: 1000, // Higher threshold for simpler documents
				maxShapesPerPage: 10000, // Allow more shapes if needed
			}}
		/>
	)
}

Measuring performance

When investigating performance issues, start with the numbers: editor.getCurrentPageShapeIds().size tells you how many shapes are on the current page and Editor#getCulledShapes tells you how many of them are hidden by culling. Then use React DevTools and Chrome's Performance tab to find slow components, and test with a production build, since development mode has overhead that production builds don't.

If performance degrades with many shapes, look for shapes that disable culling unnecessarily, components that use getZoomLevel() instead of getEfficientZoomLevel(), expensive calculations inside component render functions, and continuous animations on many shapes.

Subscribing to performance events

For programmatic monitoring (telemetry or in-app dashboards), the editor exposes PerformanceManager at editor.performance. Subscribe to events and you'll get aggregated frame-time stats from real interactions, with no overhead when no listeners are attached:

ts
const unsub = editor.performance.on('interaction-end', (event) => {
	console.log(`${event.name}: ${event.fps.toFixed(1)} fps, p95=${event.p95FrameTime.toFixed(1)}ms`)
})
// later: unsub()

The 'interaction-end' event fires when an interaction state exits, with fps, p95FrameTime, and (in supporting browsers) Long Animation Frame attribution. 'camera-end' fires after pan/zoom debounce with the same shape. 'shapes-created', 'shapes-updated', and 'shapes-deleted' carry per-type counts. 'frame' fires every animation frame while a listener is attached. See TLPerfEventMap for the full set.

Custom tools opt into interaction tracking by setting StateNode#trackPerformance on the state node class. When the state is entered, the manager starts a tracking window; when it exits, it emits 'interaction-start' / 'interaction-end' with the state path. Built-in interactions like select.translating and draw.drawing already track, so you only need this for custom states.

If you're profiling in Chrome DevTools, PerformanceApiAdapter wires the same events into native performance.mark() / performance.measure() calls so they show up on the Performance timeline:

ts
import { PerformanceApiAdapter } from 'tldraw'

const adapter = new PerformanceApiAdapter(editor.performance)
// later: adapter.dispose()
  • Culling: how viewport culling works and how to control it
  • Signals: the reactive state system
  • Store: how the reactive database batches changes
  • Options: all available editor options
  • Animation: the shape and camera animation systems