Your edit handler is slow because it is doing the reader's job
TLDR
If every edit in your app serializes the whole document to keep some other surface in sync, you are paying O(document) for an O(paragraph) change. Move that work behind a getter so it runs once per read instead of once per keystroke. On a 1236-page document this took a single Bold click from 1565ms to 167ms, and most of what disappeared was work whose result nothing ever looked at.
The shape of the problem
You have one piece of state that several surfaces render. An editor, a preview, an export path, maybe a diff view. The obvious way to keep them honest is to make every edit the moment of truth: the user types, you read the new value out of the surface they typed into, and you push it everywhere else.
That works until the document gets big. Then it gets slow in a way that is hard to attribute, because no single line is doing anything wrong.
Here is what one Bold click cost in a browser-based document editor, on a 1236-page extraction that serializes to about 16MB of HTML:
serialize the editable surface 51 ms
strip layout-only wrapper elements 4 ms
sanitize (DOMPurify) 432 ms
compare against live innerHTML 59 ms
hand the string to the code editor ~1000 ms
--------
1565 ms
The user selected three words and pressed Cmd+B. The browser applied that in under a millisecond. Everything else on that list is bookkeeping.
The naive model puts the writer in charge of the reader's needs
The code looked like this, and it looks like this in a lot of codebases:
function onEdit(surface) {
const html = surface.innerHTML; // serialize everything
applyEverywhere(html, / skip / surface);
}
function applyEverywhere(html, skipEl) { const clean = sanitize(html); // sanitize everything state.document = clean; for (const el of allSurfaces()) { if (el === skipEl) continue; // don't clobber the caret el.innerHTML = clean; } codeEditor.setValue(clean); }
Read the skipEl line again, because that is where the money went.
There is only one editable surface in this app. The caret is in it. So skipEl is always the only element in allSurfaces(), the loop body never runs, and the 432ms sanitize produced a string that was assigned to nothing.
That is not a bug in the sanitizer. The sanitizer was asked to do the work and it did. The bug is architectural: applyEverywhere computed its expensive result before it knew whether anyone wanted it.
Deriving on read makes the expensive work proportional to demand
The surface the user is typing into is already correct. It holds the edit the instant the browser applies it, before any of our code runs. So there is no state to update on write. There is only a cached copy that is now stale.
Flip the direction. An edit marks the cache dirty and returns. The string gets rebuilt on the next read, once, regardless of how many edits happened in between.
let cache = '';
let dirty = false;
Object.defineProperty(state, 'document', { get() { if (dirty) { cache = serialize(liveSurface()); dirty = false; } return cache; }, set(v) { cache = v || ''; dirty = false; }, });
function onEdit() { dirty = true; } // that is the whole edit path now
The accessor earns its keep beyond the timing. Thirteen call sites read that property and not one of them changed. Export, the diff view, the code editor, the automation interface: they all still see a plain string. What they no longer control is when it gets built.
Typing a hundred characters now marks the cache dirty a hundred times and rebuilds it zero times. The first read after that rebuilds it once.
Read frequency decides which direction is right
Pushing on write is correct in plenty of systems. It makes reads free, and if reads outnumber writes that is the trade you want.
| push on write | pull on read | |
|---|---|---|
| cost of an edit | full recompute | one flag |
| cost of a read | free | recompute if dirty |
| N edits, then 1 read | N recomputes | 1 recompute |
| stale window | none | until next read |
| debuggability | always inspectable | materializes when you look |
For a document editor the choice is still not close. Edits arrive per keystroke, reads happen when someone opens another tab or exports, and the ratio is hundreds to one.
The tab that reads the document should be the thing that triggers the read
Removing the per-edit push exposed the next question. The code editor tab still needs the current document. If an edit no longer pushes to it, who does?
The tab itself does, when it becomes visible:
export function syncEditorFromState() {
const editor = state.editor;
if (!editor) return;
const html = state.document; // getter rebuilds here, if needed
if (editor.getValue() !== html) editor.getModel()?.setValue(html);
}
That single line, state.document, is the pull. It runs once when someone opens that tab, and never for the users who spend an hour editing and never look at the code view.
Every consumer needs a moment like that. Tab activation, dialog open, export click, a request handler. If you cannot name one for some consumer, that consumer does need a push and you should keep one for it specifically.
What the numbers actually did
Measured end to end on the same document, same edit:
before: 1565 ms per Bold
after: 167 ms per Bold (9.4x)
The 167ms that remains is mostly the browser applying formatting inside a very large contenteditable root. Different problem, different fix: windowing the document so the editable root stays small took keystroke latency from 29.8ms to 1.5ms on the same file.
Both fixes were available from the start, and doing the serialization one first is what made the second visible. A 1565ms operation hides a 30ms one completely.
Two things this pattern will not fix
Deferring work does not reduce it. If your reader reads on every frame, you have rebuilt the push model with extra indirection and a dirty flag.
A getter with side effects also breaks the expectation that reading is free. Anyone who writes if (state.document.length > 0) inside a loop has written something that looks free and costs a full serialization per iteration. Comment the property, or the next person to touch it will assume it is a field.
Rule of thumb: if the surface the user edited is already correct, the edit does not need to update anything. It needs to invalidate something.
Evidence
Timings come from the Chrome DevTools Profiler against a 1236-page PDF extraction: 16MB of serialized HTML, 93,764 DOM nodes in the editable surface. The 432ms sanitize figure is the self-time of that call inside the edit path. That its result was discarded came from reading the loop it fed, not from the profile.
1565ms and 167ms are wall-clock measurements of the same Bold operation on the same document, before and after the accessor change, with no other differences in the build.