Performance

The tldraw SDK uses several techniques to maintain smooth performance even with thousands of shapes on the canvas. Understanding these systems helps you build custom shapes that perform well and avoid common pitfalls.

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

// 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():

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:

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

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

	return <path 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(shapeId) rather than calling shapeUtil.getGeometry() directly. The editor handles caching, transforms, and bounds calculation.

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:

function MyShapeComponent({ shape }) {
	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} />
}

The built-in shapes use this pattern. Pattern fills switch to solid colors when zoomed out far enough, and text shadows disable at low zoom levels (controlled by the textShadowLod option).

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, consider:

  • CSS animations for purely visual effects that don't change shape data
  • Canvas rendering for particle systems or complex animations
  • Limiting concurrent animations to a small number of shapes

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

Use stable values for zoom-dependent calculations

When calculating values that depend on zoom (stroke widths, font sizes, handle positions), use getEfficientZoomLevel() rather than getZoomLevel(). This prevents recalculations during camera movement:

// Avoid: causes re-renders during zoom
const strokeWidth = 2 / editor.getZoomLevel()

// Better: stable during camera movement
const strokeWidth = 2 / editor.getEfficientZoomLevel()

Keep component functions cheap

Shape components render frequently. Avoid expensive operations inside them:

// 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 canCull() to return false only for shapes that genuinely need to stay rendered off-screen:

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

Common reasons to disable culling:

  • Shapes that measure their DOM content to determine size
  • Shapes with visual effects (shadows, glows) that extend beyond bounds
  • 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
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:

  1. Check shape counteditor.getCurrentPageShapeIds().size tells you how many shapes are on the current page
  2. Check culled shapeseditor.getCulledShapes().size shows how many are hidden by culling
  3. Use browser profiler — React DevTools and Chrome's Performance tab help identify slow components
  4. Test with production builds — 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
  • Continuous animations on many shapes
  • 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
Prev
Visibility
Next
Translations