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 onceDebounced 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:
| Option | Default | Description |
|---|---|---|
debouncedZoom | true | Use stable zoom during camera movement |
debouncedZoomThreshold | 500 | Shape count above which debounced zoom activates |
maxShapesPerPage | 4000 | Maximum shapes allowed per page |
textShadowLod | 0.35 | Zoom 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:
- Check shape count —
editor.getCurrentPageShapeIds().sizetells you how many shapes are on the current page - Check culled shapes —
editor.getCulledShapes().sizeshows how many are hidden by culling - Use browser profiler — React DevTools and Chrome's Performance tab help identify slow components
- 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 ofgetEfficientZoomLevel() - Expensive calculations inside component render functions
- Continuous animations on many shapes