Skip to content

Performance: geometry/text queries (gBCR, innerText, evaluateAll) are 20–100x slower than Chromium on DOM-heavy pages #1617

Open

Description

@Supersynergy

Summary

LayoutPolicy::OnDemand geometry and text queries are functionally correct but ~20–100x slower than Chromium on DOM-heavy pages. On a 6000-element page an agent-style scan (querySelectorAll + getBoundingClientRect per match + innerText) costs ~2.1s on moli vs ~55ms on Chromium.

Reproduction

Test page (6000 nodes: 2000 × div.row > div.c + button + input, ~250KB HTML):

const { chromium } = require('playwright-core');
const b = await chromium.connectOverCDP('http://127.0.0.1:9222'); // MOLI_LAYOUT=true moli serve
const page = b.contexts()[0].pages()[0];
await page.goto('http://127.0.0.1:18777/heavy.html', { waitUntil: 'domcontentloaded' });

// per-op timings measured via performance.now() inside evaluate

Measured (moli 1.1.17, MOLI_LAYOUT=true, macOS arm64 vs Chromium via playwright):

operation moli chromium factor
element.getBoundingClientRect() ×4000 1541ms (~0.39ms/call) ~20ms ~75x
document.body.innerText (6k nodes) 503ms first call, 57ms repeat ~1ms ~500x / 57x
locator.evaluateAll ×4000 elements 229ms (handle materialization) ~25ms ~9x
innerText per element ×160 ~14ms ~1ms ~14x
goto + first paint, 6000-node DOM ~2.4s ~300ms ~8x
full observe scan (text+controls+rects) 2087ms ~55ms ~38x

Source reading (1.1.17 / main @ 8fac63e)

  • getBoundingClientRect → answer_layout_for_document → with_latest_layout_tree_for_document reuses the frozen published tree (good — no re-layout per call). The ~0.39ms/call is per-element client_rects_for_source lookup + quad computation. Per-element memoization exists only for LayoutPolicy::Mock (cached_mock_client_rect in context_host/activity.rs); the real-layout path has no equivalent.
  • innerText runs a rendered-text pass per call (moli-renderer-v8/native_bridge/element/content.rs); the second call drops to 57ms, suggesting partial caching but still 50x+ over Chromium.
  • evaluateAll materializes one remote object handle per match — the marshaling dominates before user JS runs.

Proposals (by leverage)

  1. Per-generation rect memoization on the real-layout path — mirror cached_mock_client_rect keyed by (DomHandle, visual_state_generation): repeat gBCR/offset*/clientRects reads on unchanged elements become ~free. Helps every geometry loop, not just scans.
  2. Batch geometry at the protocol level — a single call that resolves N element handles → N rect sets in one frozen-tree walk (or document that Runtime.callFunctionOn arg arrays should share one handle-resolution pass).
  3. innerText memoization per (document, generation) — same generation-keyed pattern; the second-call 57ms floor suggests partial caching already exists but per-element subtree text isn't memoized.
  4. Optionally document the trade-off in LayoutPolicy: OnDemand keeps a snapshot but per-query cost is O(tree-walk); for agent workloads (thousands of reads/snapshot) a generation-keyed memo table is the missing piece.

Context / mitigations already possible client-side

Callers can recover ~8–20x without engine changes:

  • Do selector-match + scan + getBoundingClientRect in one evaluate instead of evaluateAll handle materialization (2087ms → ~250ms observed).
  • Cap elements before any geometry read (160 rects ≈ 64ms instead of 1541ms).
  • Replace innerText with a TreeWalker text collector + block-boundary newlines for agent evidence (503ms → ~1ms).

Happy to provide the repro files or test a patch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions