What happened?
Time-to-first-paint (and editor-ready) scales linearly with document length, because the layout pipeline measures every block and paginates the entire document — up to several convergence passes — before a single page is painted, even though the DOM painter then virtualizes and only mounts a handful of pages.
Production observation (superdoc@2.3.0, v2 engine integration, collaborative open, app-level telemetry only): on real legal documents with comments and footnotes we measure ~75 ms/page of pre-paint work — a 55-page DOCX reaches ready in ~4.1 s, a 247-page document in ~18 s — with everything else warm (module imports 0 ms, document bytes already in memory, collaboration-ready wait <5 ms after layout finishes). The viewport shows one page; ~2–4 .superdoc-page nodes are materialized.
Reproduction in this repo's own dev harness (v1 path, AGPL, pnpm dev): a synthetic ~120-page/913-block DOCX (generator script below) with SD_DEBUG_LAYOUT timers enabled shows the same architecture at smaller magnitude on plain content:
[Perf] toFlowBlocks: 37.70ms (blocks=914)
[Perf] 4.1 Measure all blocks: 716.70ms (913 measured, 0 cached, 0 reused)
[Perf] 4.2 Layout document (pagination): 152.50ms
[Perf] 4.3 Page token resolution converged after 0 iterations
[Perf] incrementalLayout: 872.60ms
[Perf] painter.paint: 17.00ms <- first pixels, after everything above
Measure-all is 82% of the run and linear in block count; nothing paints until it and full pagination complete. On complex documents the convergence passes multiply this: page-token resolution can re-run layoutDocument up to 3× (layout-bridge/src/incrementalLayout.ts:1558-1655) and footnote reserves up to 4 more passes (:2686, MAX_FOOTNOTE_LAYOUT_PASSES at :462) — worst case the whole document is paginated up to ~8× before first paint.
Where the gate lives (AGPL source, current main):
- Single monolithic pass: measure loop over all blocks at
packages/layout-engine/layout-bridge/src/incrementalLayout.ts:1136; whole-document layoutDocument at :1539 (packages/layout-engine/layout-engine/src/index.ts:759, block loop at :2200).
resolveLayout then eagerly converts all pages on every paint (packages/layout-engine/layout-resolved/src/resolveLayout.ts:658) even though the painter mounts ~4.
- First paint happens only after all of the above:
packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:7736 (#rerender() at :7262).
- Meanwhile the painter already virtualizes beautifully (
packages/layout-engine/painters/dom/src/renderer.ts:1600, default { window: 5, overscan: 1 } from packages/superdoc/src/core/SuperDoc.ts:593-600) — the system already knows only ~4 pages matter, but layout, resolve, and the ready gate still process all of them.
IncrementalLayoutResult is a single terminal value — no partial results, no progress callback, and LayoutOptions has no maxPages/deadline/abort surface.
Notably, packages/layout-engine/tests/src/performance.bench.ts:24-29 budgets 500 ms time-to-first-paint for a 50-page document, and :191-193 notes the benchmark simulates layout, so it cannot catch this. Our production numbers are ~7.5× over that budget at 50 pages, growing linearly.
Feature requests (in order of value to us)
- Prefix paint / first-page ready: lay out and paint the first N pages, resolve ready, and complete pagination in the background (provisional
PAGE/NUMPAGES tokens repainted on completion — the footnote warm-start seed at incrementalLayout.ts:88 is precedent for provisional-then-revalidated results).
- Layout progress events (blocks measured / pages so far) surfaced on the public event bus alongside
layoutUpdated — small and additive; today there is no signal between "boot started" and "everything done".
- Demand-driven
resolveLayout (page range or lazy) — independent win; it is a whole-document pass per paint for ~4 mounted pages.
- A question: does the v2 engine integration share this design, and does v2's ready explicitly await complete layout? (In the v1 source, ready fires from a 0 ms timer —
Editor.ts:1699 — and only appears layout-gated because pagination runs synchronously.)
We would be glad to contribute the progress-events change and discuss the prefix-paint design as PRs (CLA review permitting — same team as #3862/#3863).
Steps to reproduce
git clone https://github.com/superdoc/docx-editor && cd docx-editor && pnpm install
- Surface the existing phase timers (they are keyed on
process.env.SD_DEBUG_LAYOUT, which is dead in the browser dev server): add 'process.env.SD_DEBUG_LAYOUT': JSON.stringify('1') to the define block in packages/superdoc/vite.config.js.
pnpm dev, open the harness (layout engine is on by default), upload a large DOCX, and read the [Perf] lines: nothing paints until 4.1 Measure all blocks and 4.2 Layout document complete over the whole document.
- Generator for a synthetic ~120-page test document (pure synthetic content):
# pip install python-docx
from docx import Document
from docx.shared import Pt
import random
random.seed(42)
doc = Document()
words = ("the company shall deliver notice of any material adverse change in the ordinary course "
"of business consistent with past practice including without limitation any amendment "
"modification or waiver of any provision under the agreement").split()
doc.add_heading('Synthetic Layout Benchmark Document', 0)
for section in range(1, 61):
doc.add_heading(f'Section {section}.0 - Synthetic Provisions', level=1)
for para in range(14):
text = ' '.join(random.choice(words) for _ in range(random.randint(40, 90)))
p = doc.add_paragraph(f'{section}.{para+1} ' + text.capitalize() + '.')
p.paragraph_format.space_after = Pt(8)
if section % 5 == 0:
t = doc.add_table(rows=6, cols=3)
t.style = 'Table Grid'
for r in range(6):
for c in range(3):
t.cell(r, c).text = f'Item {section}-{r}-{c}: ' + ' '.join(random.choice(words) for _ in range(8))
doc.save('synthetic-120p.docx')
Scale range(1, 61) up and add comments/footnotes to approach production magnitudes.
SuperDoc version
2.3.0 (v2 engine integration) in production; repro analysis against this repo's main (2a9df40)
Browser
Chrome 143 (macOS, M-series)
Additional context
Our interim mitigation host-side: prefetching document bytes, warming module chunks, and keeping booted editors alive across route changes (reparenting the mount node into a hidden size-constrained host — virtualization survives as long as the constrained overflow:auto container is preserved). A supported detach()/attach(container) API — or a statement that reparenting is supported behavior — would let us rely on contract rather than incident. Happy to provide traces or test branches.
What happened?
Time-to-first-paint (and editor-ready) scales linearly with document length, because the layout pipeline measures every block and paginates the entire document — up to several convergence passes — before a single page is painted, even though the DOM painter then virtualizes and only mounts a handful of pages.
Production observation (superdoc@2.3.0, v2 engine integration, collaborative open, app-level telemetry only): on real legal documents with comments and footnotes we measure ~75 ms/page of pre-paint work — a 55-page DOCX reaches ready in ~4.1 s, a 247-page document in ~18 s — with everything else warm (module imports 0 ms, document bytes already in memory, collaboration-ready wait <5 ms after layout finishes). The viewport shows one page; ~2–4
.superdoc-pagenodes are materialized.Reproduction in this repo's own dev harness (v1 path, AGPL,
pnpm dev): a synthetic ~120-page/913-block DOCX (generator script below) withSD_DEBUG_LAYOUTtimers enabled shows the same architecture at smaller magnitude on plain content:Measure-all is 82% of the run and linear in block count; nothing paints until it and full pagination complete. On complex documents the convergence passes multiply this: page-token resolution can re-run
layoutDocumentup to 3× (layout-bridge/src/incrementalLayout.ts:1558-1655) and footnote reserves up to 4 more passes (:2686,MAX_FOOTNOTE_LAYOUT_PASSESat:462) — worst case the whole document is paginated up to ~8× before first paint.Where the gate lives (AGPL source, current
main):packages/layout-engine/layout-bridge/src/incrementalLayout.ts:1136; whole-documentlayoutDocumentat:1539(packages/layout-engine/layout-engine/src/index.ts:759, block loop at:2200).resolveLayoutthen eagerly converts all pages on every paint (packages/layout-engine/layout-resolved/src/resolveLayout.ts:658) even though the painter mounts ~4.packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:7736(#rerender()at:7262).packages/layout-engine/painters/dom/src/renderer.ts:1600, default{ window: 5, overscan: 1 }frompackages/superdoc/src/core/SuperDoc.ts:593-600) — the system already knows only ~4 pages matter, but layout, resolve, and the ready gate still process all of them.IncrementalLayoutResultis a single terminal value — no partial results, no progress callback, andLayoutOptionshas nomaxPages/deadline/abort surface.Notably,
packages/layout-engine/tests/src/performance.bench.ts:24-29budgets 500 ms time-to-first-paint for a 50-page document, and:191-193notes the benchmark simulates layout, so it cannot catch this. Our production numbers are ~7.5× over that budget at 50 pages, growing linearly.Feature requests (in order of value to us)
PAGE/NUMPAGEStokens repainted on completion — the footnote warm-start seed atincrementalLayout.ts:88is precedent for provisional-then-revalidated results).layoutUpdated— small and additive; today there is no signal between "boot started" and "everything done".resolveLayout(page range or lazy) — independent win; it is a whole-document pass per paint for ~4 mounted pages.Editor.ts:1699— and only appears layout-gated because pagination runs synchronously.)We would be glad to contribute the progress-events change and discuss the prefix-paint design as PRs (CLA review permitting — same team as #3862/#3863).
Steps to reproduce
git clone https://github.com/superdoc/docx-editor && cd docx-editor && pnpm installprocess.env.SD_DEBUG_LAYOUT, which is dead in the browser dev server): add'process.env.SD_DEBUG_LAYOUT': JSON.stringify('1')to thedefineblock inpackages/superdoc/vite.config.js.pnpm dev, open the harness (layout engine is on by default), upload a large DOCX, and read the[Perf]lines: nothing paints until4.1 Measure all blocksand4.2 Layout documentcomplete over the whole document.Scale
range(1, 61)up and add comments/footnotes to approach production magnitudes.SuperDoc version
2.3.0 (v2 engine integration) in production; repro analysis against this repo's
main(2a9df40)Browser
Chrome 143 (macOS, M-series)
Additional context
Our interim mitigation host-side: prefetching document bytes, warming module chunks, and keeping booted editors alive across route changes (reparenting the mount node into a hidden size-constrained host — virtualization survives as long as the constrained
overflow:autocontainer is preserved). A supporteddetach()/attach(container)API — or a statement that reparenting is supported behavior — would let us rely on contract rather than incident. Happy to provide traces or test branches.