Skip to main content

Overview

Lexical is designed for performance, but understanding how to optimize your implementation can make the difference between a good and great user experience. This guide covers advanced performance techniques and common pitfalls.
Lexical’s reconciliation system is already highly optimized, but your custom nodes, transforms, and plugins can significantly impact performance.

Key Performance Concepts

1. Batched Updates

Lexical batches DOM updates automatically, but you should batch your state mutations:
// Bad - Multiple update cycles
for (const node of nodes) {
  editor.update(() => {
    node.getWritable().setColor('red');
  });
}

// Good - Single update cycle
editor.update(() => {
  for (const node of nodes) {
    node.getWritable().setColor('red');
  }
});

Multiple Updates

  • Each update triggers reconciliation
  • Multiple DOM passes
  • Listener called N times

Batched Update

  • Single reconciliation pass
  • One DOM update
  • Listener called once

2. Text Content Caching

Lexical caches text content on element nodes to avoid repeated traversals:
// Cached during reconciliation
dom.__lexicalTextContent = subTreeTextContent;

// Fast retrieval (from cache)
const text = element.getTextContent();
The cache is invalidated and rebuilt during reconciliation when child nodes change.

3. Node Cloning Strategy

Nodes use copy-on-write semantics:
// First getWritable() creates a clone
const writable = node.getWritable();

// Subsequent calls in same update reuse clone
const same = node.getWritable(); // Returns same instance

// Tracked in cloneNotNeeded set
editor._cloneNotNeeded.has(node.__key); // true

Optimizing Custom Nodes

Efficient updateDOM

class SlowNode extends ElementNode {
  updateDOM(prevNode: SlowNode, dom: HTMLElement): boolean {
    // Expensive: always updates even if unchanged
    dom.style.color = this.__color;
    dom.style.fontSize = this.__fontSize;
    dom.setAttribute('data-id', this.__id);
    
    return false;
  }
}

Minimize createDOM Complexity

class OptimizedNode extends ElementNode {
  createDOM(config: EditorConfig): HTMLElement {
    const element = document.createElement('div');
    
    // Set static properties only
    element.className = 'my-node';
    
    // Avoid expensive operations in createDOM:
    // ❌ Complex calculations
    // ❌ External data fetching  
    // ❌ Multiple child element creation
    
    return element;
  }
  
  updateDOM(prevNode: OptimizedNode, dom: HTMLElement): boolean {
    // Update dynamic properties here instead
    if (prevNode.__value !== this.__value) {
      dom.textContent = this.computeDisplay();
    }
    return false;
  }
}

Transform Performance

Optimize Transform Checks

// Bad - runs expensive regex on every keystroke
editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  if (/very-complex-regex-pattern/.test(text)) {
    // Transform logic
  }
});

// Good - quick bailout before expensive check
editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  
  // Quick check first
  if (!text.includes('@')) return;
  
  // Expensive check only when needed
  if (/very-complex-regex-pattern/.test(text)) {
    // Transform logic
  }
});

Avoid Infinite Loops

Lexical detects infinite transforms after 99 iterations:
// Bad - infinite loop
editor.registerNodeTransform(MyNode, (node) => {
  node.getWritable(); // Always marks dirty!
});

// Good - conditional mutation
editor.registerNodeTransform(MyNode, (node) => {
  if (needsNormalization(node)) {
    node.getWritable().normalize();
  }
});
Error: “One or more transforms are endlessly triggering additional transforms”This means your transform always marks nodes dirty. Add proper exit conditions.

Reconciliation Optimizations

Single Child Fast Path

Lexical has optimized paths for common cases:
// Fast path for single child
if (prevChildrenSize === 1 && nextChildrenSize === 1) {
  const prevChildKey = prevElement.__first!;
  const nextChildKey = nextElement.__first!;
  
  if (prevChildKey === nextChildKey) {
    // Just reconcile the one child - very fast!
    $reconcileNode(prevChildKey, dom);
  }
}

Skip Unchanged Subtrees

// If node unchanged and not dirty, skip reconciliation
if (prevNode === nextNode && !isDirty) {
  const text = dom.__lexicalTextContent || prevNode.getTextContent();
  subTreeTextContent += text;
  return dom; // Skip entire subtree
}

Command Performance

Command Priority

Use appropriate priority to avoid unnecessary processing:
// Low priority - runs last
editor.registerCommand(
  MY_COMMAND,
  (payload) => {
    // Only runs if no higher priority handler stopped propagation
    return false;
  },
  COMMAND_PRIORITY_LOW
);

// High priority - runs first, can block lower handlers
editor.registerCommand(
  MY_COMMAND,
  (payload) => {
    if (shouldHandle(payload)) {
      handleCommand(payload);
      return true; // Stop propagation
    }
    return false;
  },
  COMMAND_PRIORITY_HIGH
);

Conditional Command Registration

// Bad - always registered even when not needed
editor.registerCommand(EXPENSIVE_COMMAND, handler, PRIORITY);

// Good - register only when feature is active
let removeCommand: (() => void) | null = null;

function enableFeature() {
  removeCommand = editor.registerCommand(
    EXPENSIVE_COMMAND,
    handler,
    PRIORITY
  );
}

function disableFeature() {
  removeCommand?.();
  removeCommand = null;
}

Listener Optimization

Debounce Expensive Operations

import { debounce } from 'lodash';

const expensiveOperation = debounce((editorState) => {
  // Heavy processing
  const content = editorState.read(() =>
    processComplexContent($getRoot())
  );
  saveToServer(content);
}, 1000);

editor.registerUpdateListener(({ editorState }) => {
  expensiveOperation(editorState);
});

Use Specific Listeners

// Bad - updates on every change
editor.registerUpdateListener(({ editorState }) => {
  const text = editorState.read(() => $getRoot().getTextContent());
  updateWordCount(text);
});

// Good - only when text changes
editor.registerTextContentListener((text) => {
  updateWordCount(text);
});

Memory Management

Garbage Collection

Lexical automatically garbage collects detached nodes:
// Runs after reconciliation
export function $garbageCollectDetachedNodes(
  prevEditorState: EditorState,
  editorState: EditorState,
  dirtyLeaves: Set<NodeKey>,
  dirtyElements: Map<NodeKey, boolean>
): void {
  const nodeMap = editorState._nodeMap;
  
  for (const [nodeKey] of dirtyElements) {
    const node = nodeMap.get(nodeKey);
    
    if (node !== undefined && !node.isAttached()) {
      // Remove from node map
      nodeMap.delete(nodeKey);
    }
  }
}

Clean Up Listeners

function MyPlugin() {
  const [editor] = useLexicalComposerContext();
  
  useEffect(() => {
    const removeUpdateListener = editor.registerUpdateListener(handler);
    const removeCommand = editor.registerCommand(COMMAND, handler, PRIORITY);
    const removeTransform = editor.registerNodeTransform(Node, transform);
    
    // Critical: cleanup on unmount
    return () => {
      removeUpdateListener();
      removeCommand();
      removeTransform();
    };
  }, [editor]);
}

React Integration Performance

Memoize Plugin Components

import { memo } from 'react';

const ExpensivePlugin = memo(() => {
  const [editor] = useLexicalComposerContext();
  
  useEffect(() => {
    // Plugin logic
  }, [editor]);
  
  return null;
});

Optimize Decorator Nodes

class OptimizedDecorator extends DecoratorNode<ReactNode> {
  decorate(editor: LexicalEditor, config: EditorConfig): ReactNode {
    // Return memoized component
    return <MemoizedComponent key={this.__key} data={this.__data} />;
  }
}

const MemoizedComponent = memo(({ data }) => {
  // Expensive rendering logic
}, (prevProps, nextProps) => {
  // Custom comparison
  return prevProps.data === nextProps.data;
});

Profiling and Benchmarking

Measure Update Performance

let updateCount = 0;
let totalTime = 0;

editor.registerUpdateListener(() => {
  updateCount++;
});

const start = performance.now();

editor.update(() => {
  // Your operations
});

const end = performance.now();
totalTime += end - start;

console.log(`Average update time: ${totalTime / updateCount}ms`);

Chrome DevTools Integration

if (__DEV__) {
  // Mark reconciliation in performance timeline
  performance.mark('lexical-update-start');
  
  editor.update(() => {
    // Operations
  });
  
  performance.mark('lexical-update-end');
  performance.measure(
    'lexical-update',
    'lexical-update-start',
    'lexical-update-end'
  );
}

Performance Checklist

  • Batch related mutations in single editor.update()
  • Avoid nested update calls
  • Use discrete: true for critical updates
  • Check if update is actually needed before calling getWritable()
  • updateDOM only modifies changed properties
  • updateDOM returns true only when tag changes
  • createDOM is minimal and fast
  • Expensive computations are cached on node
  • Quick bailout conditions before expensive checks
  • Proper exit conditions to prevent infinite loops
  • Transforms are idempotent
  • Text normalization isn’t disabled unnecessarily
  • Commands have appropriate priority
  • Listeners are cleaned up properly
  • Expensive listener operations are debounced
  • Use specific listeners when possible
  • Plugin components are memoized
  • Decorator components use proper comparison
  • Context values are stable
  • Unnecessary re-renders are avoided

Benchmarks

Typical performance characteristics:

Small Updates

< 1msSingle text insertion or deletion

Medium Updates

1-5msParagraph formatting or structure changes

Large Updates

5-20msBulk operations on many nodes
These are typical ranges. Your performance may vary based on custom nodes, transforms, and complexity.

Common Performance Pitfalls

Anti-patterns to avoid:
  1. Creating editor instances in render functions
  2. Not cleaning up listeners and commands
  3. Synchronous expensive operations in update listeners
  4. Unnecessary getWritable() calls
  5. Complex computations in createDOM
  6. Always returning true from updateDOM
  7. Transforms without exit conditions
  8. Multiple small updates instead of batched

Reconciliation

Understand how Lexical updates the DOM

Node Transforms

Optimize transform performance

Testing

Benchmark and profile your editor

Headless Mode

Fastest mode for server-side operations

Build docs developers (and LLMs) love