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.
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 ;
}
}
// 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 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 ]);
}
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
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` );
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'
);
}
Benchmarks
Typical performance characteristics:
Small Updates < 1ms Single text insertion or deletion
Medium Updates 1-5ms Paragraph formatting or structure changes
Large Updates 5-20ms Bulk operations on many nodes
These are typical ranges. Your performance may vary based on custom nodes, transforms, and complexity.
Anti-patterns to avoid:
Creating editor instances in render functions
Not cleaning up listeners and commands
Synchronous expensive operations in update listeners
Unnecessary getWritable() calls
Complex computations in createDOM
Always returning true from updateDOM
Transforms without exit conditions
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