Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Overview
    • Why This Fork
    • Examples
    • Installing Plite
    • Adding Event Handlers
    • Defining Custom Elements
    • Applying Custom Formatting
    • Executing Commands
    • Saving to a Database
    • Canonical Change Substrate
    • Improving Performance
    • Interfaces
    • Nodes
    • Locations
    • Transforms
    • Document Changes
    • Commands
    • Editor
    • Plugins
    • Rendering
    • Serializing
    • Normalizing
    • Using TypeScript
    • Roots
    • Document State
    • Editing Behavior
    • Selection And DOM
    • Clipboard And Paste
    • Decorations, annotations, and widgets
    • Schema
    • Anchor API
    • Location API
    • Path API
    • PointEntry API
    • Point API
    • Range API
    • Selection API
    • Location Types APIs
    • Span API
    • Editor
    • Element API
    • NodeEntry API
    • Node API
    • Node Types APIs
    • Text API
    • Debug Value Scrubbing
    • Transforms API
    • Plite DOM
    • History Editor API
    • History Plugin Setup
    • History
    • Plite History
    • Plite Hyperscript
    • Plite PaginationExperimental
    • Annotations
    • DOM Coverage Boundaries
    • Editable Component
    • External Text Views
    • Plite React Event Handling
    • Virtualized Rendering
    • Plite React Hooks
    • React Editor Setup
    • React Editor
    • Plite React
    • Plite Component
    • Plite Yjs
    • Plite
    • Migration
    • Contributing
    • FAQ
    • Resources

Improving Performance

PreviousNext

Keep large Plite editors responsive by measuring the hot path before changing their rendering mode.

Small and moderately sized editors usually do not need special performance work; large editors do. Measure the slow path first, then change normalization, React subscriptions, DOM mounting, or painting policy based on that evidence. The Huge Document example is the local playground for document size, rendering mode, selection, typing, and rendering cost.

Measure first

This guide mostly cares about Interaction to Next Paint (INP) while typing. web.dev rates an INP of 200 ms or less as good and above 500 ms as poor.

Also measure click latency, selection latency, paste, Select All, undo/redo, and scroll stability for very large documents. Typing is not the only hot path that users feel.

INP is easiest to measure in Chrome using the Performance panel in DevTools. In Firefox, use the to inspect event and paint timelines.

Canonical Change SubstrateInterfaces

On This Page

Measure firstOptimizing Plite coreOptimizing ReactReduce rendersDOM mountingOptimizing DOM painting
Build your editor
Production-ready AI template and reusable components.
Get all-access
Firefox Profiler

Screenshot of the Stack Chart tab of the Firefox Profiler, annotated to show a breakdown of time spent in core Plite, React, and painting the DOM.

Before you start optimizing, find which of these areas is most responsible for the slowness. Use your browser's profiler (see the example for Firefox above). These rules of thumb are not Plite measurements; use them only to pick what to profile first:

  1. If the editor is much faster in Firefox than in Chrome or Safari, suspect DOM painting.
  2. If disabling your custom corrections makes it faster, suspect the corrections.
  3. Otherwise, suspect React rendering.

Optimizing Plite core

Usually, if the core Plite logic is causing a noticeable delay, it's because of normalizing. If custom normalization logic is causing slowness in your app, consider whether the logic can be made more efficient.

Use plugin corrections with an event of children, content, or properties and a narrow query. The correction worklist selects matching entries from changed paths and ancestors; a correction can run again when repairs create more work.

Keep each correction focused on its entry and direct children. Use query: "root" for document-root rules, such as enforcing a final paragraph.

Optimizing React

Reduce renders

Plite can synchronize changed text directly to the DOM. Custom subscriptions and unstable props can still cause extra element renders, so profile the components that render.

Prefer stable renderer functions passed through Editable props. Define renderElement, renderLeaf, and renderText at module scope, or memoize them once when they need editor-local data. Avoid creating renderer functions inside the editor component during render.

If unmodified elements are being re-rendered, check to see if they are subscribing to any contexts or hooks that are causing unnecessary re-renders. You can also apply these techniques to any toolbars or other non-element React components that may be re-rendering in response to changes in the editor.

Hot editor UI should subscribe to the narrowest source it needs. Use useEditorContext() to access the provider editor, useEditorSelection() for selection-aware toolbar UI, and useEditorSelector() for derived editor-level toolbar state. Inside rendered editor content, prefer target-scoped hooks such as useElementSelected(), useNodeSelector(), and useTextSelector(). Use useAnnotation(store, id) for one targeted external annotation.

If your components depend on custom React contexts containing non-primitive values (such as objects or arrays), ensure that these values are properly memoized so that components only re-render when these values change. In some circumstances, you may instead want to consider passing a ref object or an unchanging getter function to retrieve the latest value.

// Provider
const myDataRef = useRef(myData);
myDataRef.current = myData;
return <MyContext.Provider value={myDataRef}>{children}</MyContext.Provider>;
 
// Consumer
// Does not re-render when `myData` changes
const myDataRef = useContext(MyContext);
 
const onClick = () => {
  console.log(myDataRef.current);
};
// Provider
const myDataRef = useRef(myData);
myDataRef.current = myData;
return <MyContext.Provider value={myDataRef}>{children}</MyContext.Provider>;
 
// Consumer
// Does not re-render when `myData` changes
const myDataRef = useContext(MyContext);
 
const onClick = () => {
  console.log(myDataRef.current);
};

DOM mounting

Editable mounts the complete document. Start there and measure the actual interaction and memory budget. When DOM size is the proven limit, use VirtualizedEditable from plitejs/react/virtualized to opt into top-level viewport mounting.

Virtualization removes offscreen content from native browser find, accessibility traversal, print output, and third-party DOM integrations. Keep a complete surface when those jobs matter more than the DOM budget.

For paginated documents, set virtualize on PagedEditable. The paged view owns measurement, element placement, and omission at the page boundary while keeping one canvas coordinate system. The complete paged DOM remains the default.

Use stable DecorationSource objects for inline paint. Plite subscribes once per source, rereads only affected input node keys after document edits, and wakes only text nodes whose merged attributes changed. Use observe for targeted refreshes from external stores.

Optimizing DOM painting

Optimize painting when the profile shows that painting, not Plite or React, takes most of the interaction time. Profile each browser you support.

The content-visibility CSS property can reduce paint work for large documents. When set to auto, it lets the browser skip layout and painting for off-screen content while that content stays in the DOM. Before you apply content-visibility: auto to every Plite element, measure each browser you support, Safari included, with and without it.

For aggressive mounting control, use explicit virtualized rendering only when a pathological document needs a degraded viewport-mounted surface.

Use a CSS rule like this to apply spacing between top-level blocks:

[data-editor] > * + * {
  margin-top: 1em;
}
[data-editor] > * + * {
  margin-top: 1em;
}

Keep spacing rules attached to the current editor DOM, not to internal runtime structures.

Also bear in mind this warning about content-visibility: auto from MDN:

Since styles for off-screen content are not rendered, elements intentionally hidden with display: none or visibility: hidden will still appear in the accessibility tree. If you don't want an element to appear in the accessibility tree, use aria-hidden="true".