Performance

Performance considerations and optimization strategies for contenteditable elements.

Overview

contenteditable performance can degrade significantly with large content, frequent DOM mutations, or certain CSS properties. This guide covers common performance issues and optimization strategies.

⚠️ Performance Bottlenecks

Common performance issues:

  • Large DOM trees (thousands of nodes) cause typing lag
  • MutationObserver callbacks fire too frequently
  • CSS filters and transforms trigger expensive repaints
  • Virtual scrolling libraries interfere with selection
  • Frequent programmatic DOM updates cause reflow

Large Content Performance

⚠️ Typing Lag with Large Content

Problem: When a contenteditable contains thousands of DOM nodes, typing becomes noticeably slow. There's a visible lag between pressing keys and seeing characters appear.

// ❌ BAD: Large DOM tree causes lag
<div contenteditable>
  <!-- 10,000+ DOM nodes -->
  <p>Node 1</p>
  <p>Node 2</p>
  <!-- ... thousands more ... -->
</div>
// Typing becomes slow!

// ✅ GOOD: Limit visible content, use pagination or virtualization
<div contenteditable>
  <!-- Only render visible portion -->
  <div class="visible-content">
    <!-- ~100-200 nodes max -->
  </div>
</div>

Optimization Strategies

  • Pagination: Split content into pages and only render the current page
  • Virtual Scrolling: Only render visible nodes (see Virtual Scrolling section)
  • Lazy Loading: Load content on demand as user scrolls
  • Debounce Updates: Batch DOM updates instead of applying them immediately
  • Document Fragments: Use DocumentFragment for bulk DOM operations
// Example: Debounce DOM updates
let updateTimeout;
function updateContent(newContent) {
  clearTimeout(updateTimeout);
  updateTimeout = setTimeout(() => {
    // Batch all updates together
    const fragment = document.createDocumentFragment();
    // ... build fragment ...
    element.appendChild(fragment);
  }, 16); // ~60fps
}

MutationObserver Performance

⚠️ MutationObserver Callback Overhead

Problem: When a MutationObserver is attached to a contenteditable element, frequent DOM mutations during typing can trigger many observer callbacks, causing lag or jank.

// ❌ BAD: Observer fires on every mutation
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    // Expensive operation on every mutation
    processMutation(mutation);
  });
});

observer.observe(element, {
  childList: true,
  subtree: true,
  characterData: true
});
// Fires too frequently during typing!

// ✅ GOOD: Batch mutations and debounce processing
let mutationQueue = [];
let processTimeout;

const observer = new MutationObserver((mutations) => {
  mutationQueue.push(...mutations);
  
  clearTimeout(processTimeout);
  processTimeout = setTimeout(() => {
    // Process all mutations at once
    processMutations(mutationQueue);
    mutationQueue = [];
  }, 50); // Batch every 50ms
});

observer.observe(element, {
  childList: true,
  subtree: true,
  characterData: true
});

Best Practices

  • Batch Processing: Collect mutations and process them in batches
  • Debounce: Use setTimeout to delay processing until typing pauses
  • Filter Mutations: Only process relevant mutations (ignore text node changes during typing)
  • Disconnect When Not Needed: Temporarily disconnect observer during rapid typing
  • Use requestIdleCallback: Process mutations during idle time when available
// Example: Filter and batch mutations
const observer = new MutationObserver((mutations) => {
  // Filter out text node changes during typing
  const relevantMutations = mutations.filter(mutation => {
    if (mutation.type === 'characterData') {
      // Ignore text changes during active typing
      return false;
    }
    return true;
  });
  
  if (relevantMutations.length > 0) {
    batchProcess(relevantMutations);
  }
});

CSS Performance Impact

⚠️ CSS Filters and Transforms

Problem: CSS filters (blur, brightness, etc.) and transforms can cause performance degradation. Typing may lag, and selection updates may be slow.

// ❌ BAD: Expensive CSS filters
.contenteditable {
  filter: blur(2px) brightness(1.2);
  transform: scale(1.05);
}
// Causes repaints on every keystroke!

// ✅ GOOD: Use CSS containment and optimize filters
.contenteditable {
  /* Use will-change carefully */
  will-change: contents;
  /* Use CSS containment to limit repaints */
  contain: layout style paint;
  /* Avoid expensive filters during editing */
  /* Apply filters only when not editing */
}

.contenteditable:focus {
  filter: none; /* Remove filters during editing */
}

CSS Optimization Tips

  • Avoid Filters During Editing: Remove filters when element is focused
  • Use CSS Containment: contain: layout style paint limits repaint scope
  • will-change Carefully: Use will-change sparingly - it creates layers and uses memory
  • Avoid Backdrop Filters: backdrop-filter is very expensive
  • Use Transform Instead of Position: Transforms are GPU-accelerated

Virtual Scrolling

⚠️ Virtual Scrolling Interference

Problem: When contenteditable is used with virtual scrolling libraries, the virtual scrolling mechanism may interfere with text selection and caret positioning. Selection may be lost when elements are removed from the DOM during scrolling.

// ❌ BAD: Virtual scrolling removes selected nodes
function onScroll() {
  // Remove nodes outside viewport
  removeNodesOutsideViewport();
  // Selection lost if selected node was removed!
}

// ✅ GOOD: Preserve selection during virtual scrolling
function onScroll() {
  // Save selection before DOM changes
  const savedSelection = saveSelection();
  
  // Remove nodes outside viewport
  removeNodesOutsideViewport();
  
  // Restore selection if nodes still exist
  if (canRestoreSelection(savedSelection)) {
    restoreSelection(savedSelection);
  }
}

function saveSelection() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  const range = selection.getRangeAt(0);
  
  // Store node references and offsets
  return {
    startPath: getNodePath(range.startContainer),
    startOffset: range.startOffset,
    endPath: getNodePath(range.endContainer),
    endOffset: range.endOffset
  };
}

function getNodePath(node) {
  const path = [];
  while (node && node !== element) {
    const index = Array.from(node.parentNode.childNodes).indexOf(node);
    path.unshift(index);
    node = node.parentNode;
  }
  return path;
}

Virtual Scrolling Best Practices

  • Preserve Selection: Always save and restore selection before DOM changes
  • Use Node Paths: Store node paths (indices) instead of direct references
  • Buffer Zone: Keep extra nodes above/below viewport to prevent selection loss
  • Debounce Scrolling: Don't update DOM on every scroll event
  • Test Selection: Verify selection is maintained after scrolling

Optimization Strategies

1. Debounce and Throttle

Debounce or throttle expensive operations:

function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

// Debounce DOM updates
const updateDOM = debounce((content) => {
  element.innerHTML = content;
}, 100);

// Throttle selection tracking
const trackSelection = throttle(() => {
  const selection = window.getSelection();
  // ... track selection ...
}, 50);

2. Use Document Fragments

Batch DOM operations using DocumentFragment:

// ✅ GOOD: Use DocumentFragment for bulk operations
function insertMultipleNodes(nodes) {
  const fragment = document.createDocumentFragment();
  nodes.forEach(node => fragment.appendChild(node));
  element.appendChild(fragment); // Single DOM update
}

// ❌ BAD: Multiple DOM updates
function insertMultipleNodes(nodes) {
  nodes.forEach(node => {
    element.appendChild(node); // Multiple updates
  });
}

3. Use requestAnimationFrame

Schedule DOM updates during the next frame:

function updateContent(content) {
  requestAnimationFrame(() => {
    element.innerHTML = content;
    // Update happens during next frame
  });
}

4. Minimize Reflows

Batch style reads and writes to minimize reflows:

// ❌ BAD: Causes multiple reflows
element.style.width = '100px';
const height = element.offsetHeight; // Reflow
element.style.height = '200px';
const width = element.offsetWidth; // Reflow

// ✅ GOOD: Batch reads and writes
// Read all properties first
const height = element.offsetHeight;
const width = element.offsetWidth;

// Then write all properties
requestAnimationFrame(() => {
  element.style.width = '100px';
  element.style.height = '200px';
});

Platform-Specific Issues

Browser-Specific Issues

⚠️ Chrome: Large DOM Performance

Chrome: Performance degrades more noticeably with large DOM trees compared to Firefox. Consider virtual scrolling or pagination for Chrome users.

⚠️ Safari: MutationObserver Overhead

Safari: MutationObserver callbacks may have more overhead in Safari. Consider debouncing more aggressively.

⚠️ Firefox: CSS Filter Performance

Firefox: CSS filters may have worse performance in Firefox. Avoid filters during active editing.

Device-Specific Issues

⚠️ Mobile: Limited Performance

Mobile devices: Performance is more limited on mobile. Consider:

  • Reducing DOM complexity on mobile
  • Using simpler CSS (avoid filters/transforms)
  • Implementing aggressive debouncing
  • Limiting content size on mobile devices

Related resources