a YC-backed documentation platform
The keystroke that cost 1.4 seconds
Typing in large documents had lagged for years — up to 1.4 seconds from keystroke to paint. The fix, when it finally surfaced, was +327 lines across 10 files. No rewrite, no new architecture. This is the story of why it was so hard to find, and what the profiler wasn't saying.
- Keystroke latency, large docs
- 1.4s → ~100ms14× faster
- Size of the fix
- years-old problem → +327 / −3610 files · one PR
Surface: block wrappers, editor shell, sidebar, table of contents · Method: DevTools profiling on real large documents — no guessing · Stack: React · Slate/Plate rich-text editor · Jotai · real-time collaboration
The problem: when the editor is the product, lag is churn
This is not a marketing site with a slow page. The product is a collaborative rich-text editor — documents with hundreds of blocks, tables, embedded API consoles, live-synced between collaborators. On long documents it had gotten slow enough that typing visibly lagged behind the keyboard: up to 1.4 seconds for a character to appear. That is the kind of slow that makes people quietly stop using a tool.
The problem was old, and it had survived because every previous look at it reached the same dead end: no function in the codebase was slow. The usual suspects — network, the collaboration layer, the framework — all measured clean. "Just make it faster" looked like it meant a rewrite. It didn't. It meant finding where a cheap thing was being multiplied.
01 · The hunt — the profiler blames a phase, not a function
Profiling real editing sessions on large documents showed the time going to style recalculation — the browser's own phase, after JavaScript, where changed DOM is re-matched against CSS. That's the finding that makes this bug hard: Recalculate Style has no stack frame in your code. No app function to optimize, nothing to cache. The flame chart points at the browser and shrugs.
The only way forward is to reason backwards: style recalculation is proportional to how much DOM gets touched, so the real question became why does one keystroke touch thousands of DOM nodes? Tracing renders instead of time revealed the answer — on every keystroke, React was re-rendering essentially the entire screen:
one keystroke
└─ document value lives in page state → the whole page re-renders
├─ 29 callback props recreated, none memoized ──▶ sidebar: full doc-tree re-render
├─ value / Object.values() arrays: new identities ──▶ editor shell memo defeated
└─ inside the editor
├─ every block's wrapper subscribed to editor state ──▶ ALL blocks re-render
├─ each block: findPath + Range.intersection ──▶ O(doc) work × N blocks
└─ TOC + drawer portals walk the whole document again
= thousands of DOM nodes touched → Recalculate Style → 1.4 s
Nothing in that tree is individually expensive. Memoization existed everywhere and looked correct in review. It had simply been defeated at every level — from above by unstable prop identities, from below by subscription hooks — and each defeat was invisible unless you were counting renders.
Why it stayed hidden for years. There was no hot function, only hot breadth. Every component was fast; the bug was the multiplication — cheap work × every block × every keystroke. Profilers rank functions, and this bug doesn't have one.
02 · Leak one — the subscription that re-rendered every block
Every block in a document renders through a shared wrapper component — the thing that draws selection outlines, hover states, drag handles. That wrapper called the editor library's subscribing hook, which re-renders its caller on every editor change. One line, multiplied by every block in the document, silently bypassing the per-element memoization the editor framework itself provides:
- const editor = useEditorState() // subscribes → every block, every keystroke
+ const editor = useEditorRef() // stable ref → blocks re-render only when
// their own element actually changed
The same wrapper computed "is the cursor inside this block?" with a generic hook that ran
findPath plus a range intersection — a document-sized computation, per block. Its
replacement is three lines and O(1): read the selection anchor's top-level index, and compare
that node's ID with this block's ID.
And the wrapper's memoized computations listed editor.children as a dependency — an object
that gets a new identity on every keystroke, making those useMemo calls decorative. Each was
re-keyed to what it actually depends on: the block's own element reference (which the editor
renews precisely when that element changes) or the hovered-block ID.
03 · Leak two — twenty-nine unstable callbacks
Above the editor, the docs page passes the sidebar 29 callback props — navigation, drag
handlers, space switching — none wrapped in useCallback. Since the document value lives in
page state, every keystroke re-rendered the page, recreated all 29 functions, and defeated the
sidebar's memoization — re-rendering the entire document tree for every character typed. The
editor shell suffered the same pattern: fresh callbacks, plus data arrays rebuilt with
Object.values() into new references around identical content.
Wrapping 29 call sites in useCallback would have been churn and would rot. The fix is a
reusable hook built on a different idea — stable proxies that delegate to the latest
version:
// Created ONCE at mount. Never changes identity.
callbacks[key] = (...args) => latestPropsRef.current[key](...args)
// latestPropsRef.current = props ← updated every render, so the proxy
// always calls the newest closure — while the memoized child below
// sees function references that never change.
A thin outer wrapper applies it and hands everything to a memoized inner component. The parent
stays free to write inline arrow props forever; the child stops caring. A sibling hook does
the equivalent for arrays — keep the old reference when contents are element-wise identical.
And the editor shell got one more freeze: the document value prop is only ever used as the
editor's initial value (the editor owns its state afterwards), so the shell now pins it
after mount and swaps it only when a different document loads.
04 · Leak three — companions that watched everything, cared about little
The table of contents and the side-drawer panel both derived their state by walking the full document — on every change. But a TOC only changes when headings change; the drawer panel only when drawer-type blocks appear, disappear, or get renamed. Typing inside a paragraph is irrelevant to both. The fix: subscribe through a selector that returns a fingerprint string of just the structure each companion cares about:
const headingsChanged = useEditorSelector((editor) => {
let s = ''
for (const child of editor.children) {
if (isHeading(child)) s += `${child.type}:${child.id}:${text(child)}|`
}
return s // same string → no re-render. Typing in a paragraph: same string.
}, [])
The selector compares the returned string with the previous one; the component re-renders only when a heading is added, removed, reordered, retitled, or an API block changes its name — exactly the events a TOC exists to reflect. The drawer-anchor recomputation got the same treatment plus a 300 ms debounce, since its output feeds a store rather than the keystroke path.
05 · Result — fourteen times faster, nothing rewritten
Measured the same way it was diagnosed — profiling real editing sessions on large documents — keystroke latency dropped from 1.4 seconds to around 100 milliseconds. A keystroke now re-renders the block under the cursor instead of the screen. The sidebar, TOC, and drawer panels sit idle during typing and wake only for the events that concern them. Typing feels instant again.
| Dimension | Before | After |
|---|---|---|
| Keystroke → paint, large docs | up to 1.4 s | ~100 ms |
| Blocks re-rendered per keystroke | all of them | the edited one |
| Selection check, per block | path find + range intersection | O(1) ID comparison |
| Sidebar / TOC / drawers on typing | full re-render + full doc walk | idle (fingerprint unchanged) |
| Diff | 10 files · +327 / −36 | no API or architecture change |
None of it was a rewrite. It was profiling, render discipline, and identity discipline applied to the one path users feel most — with every change small enough to read in a review, and commented in place so the next engineer knows why the ref hook, the frozen value, and the fingerprint selectors must stay.
06 · Retrospective — what this bug teaches
- Profile the phase, then reason backwards. When the flame chart blames style recalculation, the question isn't "what code is slow" but "what is touching all this DOM." Render counts, not milliseconds, located every leak.
- Memoization is a chain, not a feature. One subscribing hook below, or one unstable prop
above, turns
memo()into decoration — with zero warnings. Every leak here lived in code that looked correctly memoized. - Subscribe to the smallest possible thing. A stable ref where no reactivity is needed; a fingerprint selector where only structure matters; the element itself rather than the whole tree as a dependency. Granularity is the entire game.
- Identity is API. The proxy-callback hook stabilizes reference identity once, at one
choke point, instead of asking every parent to remember
useCallbackforever. Fixes that rely on discipline decay; fixes that remove the need for it don't. - Years-old bugs are rarely deep — they're diffuse. The fix was small because the cause was never one thing; it was the same cheap mistake repeated at four layers, and it only hurt when multiplied by document size. Finding it was the work. A faster editor is one people keep using — the job was making the fast version the one that ships.
Latency figures are from DevTools profiling sessions on large production documents, before and after the fix; diff figures are measured from the PR. Identifying details — product name, package names — have been altered or omitted. Companion pieces: An AI agent that rewrites your documentation, Git-style branching for documents, and Statistics, not stack traces.