Performance
Folio's first non-negotiable is extreme read/write performance at book scale. The benchmark is a generated book of ~5,000+ pages with images, tables, code, math and Arabic. An edit may only do work proportional to what it changed.
The non-negotiables
- Work ∝ change. Identity-cached block measurement, page-object reuse, incremental pagination with convergence, a lazy per-page position index. No whole-document passes beyond O(blocks) bookkeeping.
- No per-page work for invisible pages. Page fragments are lazy; the view is virtualized; selection highlights accept a visible page window.
- Nothing on the typing path that can wait. Toolbar state renders at low priority, saves are debounced and batched, checksums are memoized.
Numbers
Playground stress document: 7,314 pages, 35,019 blocks with tables, code, images, math and Arabic. Chrome, React development mode.
| before | now | |
|---|---|---|
| Open (cold layout, real HarfBuzz) | 20.7 s | 2.3 s |
| Keystroke, engine (transaction + layout + index) | 49 ms | 9.5 ms |
| Keystroke, keydown → painted frame | 209 ms | 28 ms avg / 38 ms p95 |
| Enter (shifts every later block) | 731 ms | 50 ms |
| JS heap | 3.4 GB | ~460 MB |
Loading the same book through the mock API (@nextgensoftwares/folio-sync):
| First page visible | ~0.1 s |
| Full load, cold, simulated Wi-Fi (44 MB) | 7.3 s |
| Full load, warm from IndexedDB (one 12 KB request) | 2.5 s |
| Peak heap (including the in-tab mock server) | ~0.85 GB |
| Result checksum, cold / per save | ~60 ms for 35k blocks / ~4 ms |
Where the speed comes from
- Segment-width summing. Lines are built from measured segments between break opportunities; whole lines are never reshaped. Font fast paths skip segmentation when the primary face covers the text.
- Incremental layout. Prefix/suffix flow reuse, pagination resumes at the first affected page and stops on convergence; verified equal to full layout by a 300-edit randomized test. See Incremental layout.
- Lazy page fragments and page-object reuse, so renderers skip unchanged pages by identity.
- Lazy per-page position index, cached per page object.
- Verified fast content matching for ProseMirror's flat top-level
doc. - Formatter cached by neighbour identity: a keystroke re-evaluates three blocks.
- Virtualized page list with O(1) scroll → page window math.
- Deferred toolbar (
useDeferredEditorVersion) andeditor.uiState()for cheap availability checks after select-all. - HarfBuzz buffer reuse: one buffer per engine, so the WASM heap doesn't balloon over millions of measurements.
What remains O(blocks) per edit is ProseMirror's flat top-level array (resolve, replace) and integer bookkeeping. The planned fix is Word-like section nodes.
Rules for plugin and host authors
These rules come from real regressions; each one was pinned by a fix.
Never do per-page work for pages nobody looks at
Reading page.fragments builds that page. Use page.blocks (the top-level block range, always available) and binary search to find pages, and read fragments only for the pages you draw or the one page you need (the TOC plugin finds a heading's page in O(log pages) and reads one page's fragments).
Preserve identity, and cache by it
Layout, page reuse, the position index, the formatter and checksums all key on object identity. In your code:
- Never mutate a document node; copy on write.
- Memoize derived nodes so the same input gives the same object (the TOC plugin memoizes its substituted node per
(node, data)pair, so the layout cache sees an unchanged block). - Keep layout inputs stable: theme, renderer maps, header/footer config, variables. Plain objects are compared shallowly, but nested objects rebuilt per call still break reuse.
- Cache per node in a
WeakMap, never in aMapkeyed by index.
Create long-lived closures at module level
V8 shares one closure context between all closures created in the same scope. A lazy getter created inside a big function keeps everything that scope references alive, including the previous layout, which references the one before it. That once chained every layout generation in memory. Folio builds the closures pages keep (fragmentsOf, chromeOf, constant, lazyPage) in small module-level helpers that capture only page-local data. Do the same for anything that outlives a call.
Make lazy getters non-enumerable
Generic walkers (devtools prop diffs, loggers, JSON.stringify, deep-equal) touch every enumerable property. An enumerable lazy getter on 7,000 pages materializes 7,000 pages. Define lazy properties with enumerable: false.
Keep availability checks cheap
when / enabled / active on UI items run on every (deferred) render. Use the UIContext.state you're given (it's uiState(), truncated for huge selections), call commands without dispatch, and stop at the first match instead of walking the whole selection.
Keep the typing path short
Only follow transactions that request it (tr.scrollIntoView()) for caret auto-scroll; throttle progress UIs (the playground updates load progress at most ~4×/s); keep transient state (upload progress) out of the document so it doesn't become transactions, undo entries and relayouts.
Measuring
Playground bench: load the "Stress: ~5,000 pages" sample, open the bench panel and run the editing benchmark. It types 30 characters, presses Enter 5 times and Backspace 10 times in the middle of the book and reports engine time and full-frame time (avg / p95 / max), then undoes.
tools/load-memory.mjs: assembles a big book chunk by chunk through the in-memory server and prints heap usage per 10 chunks, to catch per-chunk retention.shpnpm build node --expose-gc tools/load-memory.mjs 130 # chapters (default 130 × 271 blocks)tools/chrome-compare.mjs: fidelity, not speed; see Fonts & measurement.Unit benches live next to the code (
plugin-toc/src/__tests__/stress.bench.test.ts,export-pdf/tools/bench.test.ts).