How to Optimize Excalidraw Performance for Large Drawings and Complex Scenes

Batch element mutations with scene.mapElements(), leverage the built-in selected-element cache, and schedule heavy computations inside requestAnimationFrame to keep Excalidraw responsive even with thousands of elements.

Excalidraw is a vector-drawing editor that stores every shape as a JavaScript object and renders them onto an HTML canvas on each frame. When scenes grow to thousands of elements or contain complex geometry, naive updates can cause noticeable lag. This guide explains the architectural optimizations built into the excalidraw/excalidraw repository and shows how to leverage them when building custom extensions or processing large drawings.

Core Architectural Strategies

Avoid Unnecessary Re-renders with Scene.mapElements

The Scene class in packages/element/src/Scene.ts (lines 42-53) provides a mapElements method that only replaces the element array when at least one element actually changes. This prevents a full canvas redraw when a bulk operation ends up being a no-op. By returning the original element reference from the callback when no mutation occurs, React-style diffing skips unnecessary reconciliation work.

Cache Expensive Look-ups

Selected-element lookups are cached in selectedElementsCache within packages/element/src/Scene.ts (lines 8-20). The cache is invalidated only when the underlying element list changes, eliminating the need to scan the entire array on every UI tick. Always use scene.getSelectedElements() without passing a custom elements array to leverage this optimization.

Batch DOM-to-Canvas Work via RequestAnimationFrame

All canvas drawing happens inside a request-animation-frame (RAF) loop managed by AnimationController.tick in packages/excalidraw/renderer/animation.ts (lines 36-70). This groups many small mutations into a single paint operation. When building custom tools, schedule heavy logic inside requestAnimationFrame to align with Excalidraw's rendering cycle.

Minimize Array Allocations

When mutations do not affect element order or size, the original array reference is preserved. The replaceAllElements method in packages/element/src/Scene.ts (lines 71-84) ensures that unchanged arrays maintain reference equality, allowing React-like diffing to skip work entirely.

Off-Screen Canvas Caching

Icon-type elements such as link icons are stored in a per-key canvas cache. The linkIconCanvasCache in packages/excalidraw/renderer/staticScene.ts (lines 187-210) ensures these assets are drawn only once per frame, reducing repeated rendering overhead for complex UI chrome.

Throttled Validation

Element-index validation runs only when necessary. The validateIndicesThrottled function in packages/element/src/Scene.ts (lines 81-84) ensures that expensive consistency checks execute only when the element list actually mutates, not on every render.

Practical Techniques for Large Drawings

Batch Updates with mapElements

When transforming many elements—such as changing the stroke width of all rectangles—use scene.mapElements. The callback must return the original element if nothing changes; otherwise return a new element object.

// Example: increase stroke width of every rectangle by 1px
scene.mapElements((el) => {
  if (el.type === "rectangle") {
    return { ...el, strokeWidth: el.strokeWidth + 1 };
  }
  // No change → return the original reference
  return el;
});

Because mapElements only calls replaceAllElements when at least one element changed, the scene’s nonce refreshes once instead of on every iteration, dramatically reducing RAF cycles.

Defer Heavy Computation to RAF

If running a CPU-heavy algorithm—such as a layout engine—after a user action, schedule it inside requestAnimationFrame to avoid blocking the current paint.

requestAnimationFrame(() => {
  const newElements = heavyLayoutAlgorithm(scene.getElements());
  // Apply the result in a single batch
  scene.replaceAllElements(newElements);
});

The built-in animation loop already uses RAF (AnimationController.tick), so your callback executes right before the next canvas draw.

Leverage the Selected-Element Cache

Fetching currently selected elements is common in plugins. Use scene.getSelectedElements(opts) without passing a custom elements array so the internal cache can be reused.

const selected = scene.getSelectedElements({
  selectedElementIds: appState.selectedElementIds,
});

Avoid calling scene.getElements() and filtering manually; the cache prevents a full scan of the element list on every UI tick.

Reduce Canvas State Changes

When drawing custom shapes, reuse a single canvas context and avoid repeatedly calling ctx.save() / ctx.restore(). The renderer groups all element draws inside one context, but extra state changes in user-provided plugins can add overhead.

// Bad: many save/restore in a loop
elements.forEach(el => {
  ctx.save();
  // ... set styles per element
  draw(el);
  ctx.restore();
});

// Good: set style once if possible
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
elements.forEach(el => draw(el));

Limit Re-renders on Interaction

If creating a custom UI that updates app state frequently—such as a slider changing opacity of many elements—throttle state updates with requestAnimationFrame or a debounce function, then apply the change in a single batch.

let rafId: number | null = null;
function onSliderChange(value: number) {
  if (rafId) cancelAnimationFrame(rafId);
  rafId = requestAnimationFrame(() => {
    scene.mapElements(el => ({
      ...el,
      opacity: value,
    }));
    rafId = null;
  });
}

Key Source Files to Study

Understanding the internals of these modules helps when building high-performance extensions:

Summary

  • Batch mutations – Use scene.mapElements and scene.replaceAllElements to apply many changes in a single render pass.
  • Cache look-ups – Rely on built-in selected-element cache rather than manual loops.
  • Leverage RAF – All heavy work should be scheduled with requestAnimationFrame; the editor already consolidates rendering into an RAF tick.
  • Minimize canvas state churn – Draw custom content with as few save/restore calls as possible.
  • Understand the core files – Scene.ts, the animation controller, and the static-scene renderer hold the performance-critical logic.

By following these patterns you can keep Excalidraw snappy even when users work with tens of thousands of elements or highly detailed drawings.

Frequently Asked Questions

How does Excalidraw handle thousands of elements without lagging?

Excalidraw uses several architectural strategies to maintain performance with large scenes. The Scene class batches element updates through mapElements, only triggering a redraw when actual changes occur. It also caches selected-element lookups and groups all canvas drawing inside a requestAnimationFrame loop managed by AnimationController.tick. These optimizations prevent the UI from blocking during heavy operations.

What is the best way to update many elements at once in Excalidraw?

Use the scene.mapElements() method to batch updates. This function iterates over all elements and only replaces the internal array if at least one element actually changes, which minimizes React-style reconciliation and canvas redraws. Always return the original element reference from the callback when no mutation occurs to preserve reference equality and skip unnecessary work.

How can I prevent custom plugins from slowing down Excalidraw?

Schedule heavy computations inside requestAnimationFrame to align with Excalidraw's rendering cycle, and minimize canvas context state changes by avoiding excessive ctx.save() and ctx.restore() calls. When fetching selected elements, use scene.getSelectedElements() without passing a custom elements array to leverage the internal cache, rather than manually filtering the full element list.

Where are the main performance optimizations implemented in the Excalidraw codebase?

The critical logic resides in packages/element/src/Scene.ts (batching and caching), packages/excalidraw/renderer/animation.ts (RAF loop), and packages/excalidraw/renderer/staticScene.ts (off-screen canvas caching). Additional utilities for timing and RAF scheduling are found in packages/common/src/utils.ts and packages/excalidraw/laser-trails.ts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →