Files
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

252 lines
12 KiB
Plaintext

---
title: Performance
status: published
author: steveruizok
date: 1/31/2026
keywords:
- performance
- optimization
- culling
- signals
- batching
- zoom
- rendering
- LOD
- level of detail
- image resolution
---
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](/sdk-features/culling) for details on how to control this behavior for custom shapes.
### Reactive signals
The SDK uses reactive [signals](/sdk-features/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](/sdk-features/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](#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](/sdk-features/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](/sdk-features/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 |
```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()
```
## Related
- [Culling](/sdk-features/culling): how viewport culling works and how to control it
- [Signals](/sdk-features/signals): the reactive state system
- [Store](/sdk-features/store): how the reactive database batches changes
- [Options](/sdk-features/options): all available editor options
- [Animation](/sdk-features/animation): the shape and camera animation systems