Engineering

Virtualising long documents in an editor

Rendering only the visible part of a document is the wrong first fix. Measure the cost, limit decoration work to the changed paragraph, and keep heights and page breaks stable.

7 min read

A 200-page document in a browser editor is a large tree of paragraphs, marks and images, and every keystroke asks the browser to lay out a part of it. Typing starts to lag, scrolling stutters, and the page takes seconds to open. The first instinct is to virtualise the list, rendering only the visible part as the reader scrolls. That works for a feed of independent items. For a document, where paragraphs flow into one another and page breaks depend on their heights, it creates problems that are hard to fix later.

Measure before you virtualise

Most slow editors are slow for one of three reasons: too many nodes in the DOM, a layout forced on every change, or a decoration pass that runs over the whole document. Each has a different fix. Open the browser’s performance panel, type in the middle of a long document, and find which of these dominates. Virtualising solves the first only. Applying a decoration only to the changed paragraph solves the third, and it is often the cheaper win.

Limit work to the changed paragraph

Spell check markers, comment highlights and search matches are decorations on top of the content. Recomputing all of them on every keystroke is a common cause of lag, and it is also unnecessary, because a keystroke changes one paragraph. Recompute decorations only for the range that changed, and keep the rest from the previous pass. The cost then grows with the size of the edit, not with the size of the document.

Code example
function updateDecorations(
  prev: DecorationSet,
  doc: ProseNode,
  changed: { from: number; to: number },
) {
  // Map the old decorations through the edit, then recompute only the paragraphs touched.
  const mapped = prev.map(changedMapping, doc);
  const $from = doc.resolve(changed.from);
  const $to = doc.resolve(changed.to);
  const start = $from.start($from.depth);
  const end = $to.end($to.depth);
  return mapped.remove(mapped.find(start, end)).add(doc, findSpellErrors(doc, start, end));
}

The sample is illustrative: the helper names stand in for whatever your editor provides. The principle is what matters. Old decorations are carried forward through the edit, and only the paragraphs that changed are checked again.

Keep heights stable before you hide anything

Virtualisation replaces off-screen content with a placeholder of estimated height. When the estimate is wrong, the scrollbar jumps as the reader scrolls, and the position they were reading moves. Measure the real height of each block as it is rendered and keep the measurement, so later estimates improve. Images and tables make the estimate hardest, because their height is unknown until they load. Reserve their space from their stored dimensions, not from the loaded image.

Page breaks and virtualisation do not mix easily

A print layout that shows page boundaries needs the height of every block, because a break falls wherever the content reaches the bottom of a page. If only the visible blocks are rendered, the editor cannot know where the later page breaks fall without measuring them. Either keep page layout as a separate pass that runs off screen, or drop the on-screen page breaks in favour of a plain flowing view while editing, and show the true pages in print preview.

A search that only looks at rendered paragraphs misses matches further down the document, and the count it shows is wrong. Search the document model, not the DOM. Highlight the match by scrolling the paragraph into view, which renders it on demand. Keep the match count as a number from the model, so it is accurate before the reader scrolls to any of them.

A short test list


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.