Skip to content

web_snapshot O(tree-size) slow: enrichNodeWithLocatorBounds does per-node locator round-trips (~5s budget-cap on heavy pages) #199

Description

@basementbot

Summary

Capturing screen state on a DOM-heavy page (e.g. a flight-search results page, ~8k DOM nodes) is dramatically slower than it should be — on the order of ~9–12 s, vs ~1.5 s for the accessibility-only path on the same page. Profiling shows the cost is entirely in per-node Playwright locator round-trips during view-hierarchy bounds enrichment, not in the DOM/JS work.

Root cause (profiled)

jstack-sampling the running daemon during a capture shows the hot method is:

xyz.block.trailblaze.playwright.PlaywrightScreenState.enrichNodeWithLocatorBounds

enrichNodeWithLocatorBounds recurses the ~thousand-node view-hierarchy tree and, for every node, makes individual Playwright locator round-trips:

PlaywrightAriaSnapshot.resolveElementRef(page, ElementRef(descriptor, nth))
locator.count()
locator.boundingBox(...)

That's ~2–3 CDP round-trips × N nodes. It's budget-capped at ENRICHMENT_BUDGET_MS = 5_000L, so on heavy pages it burns the entire 5 s budget and still returns partial/incomplete bounds (the "enrichment budget exceeded — returning partial bounds" log).

Evidence (isolated Playwright benchmark, same page, ~8.3k DOM nodes)

A) per-node handle.boundingBox() × 800 nodes:        304 ms   (0.4 ms/node, 1 round-trip)
B) batched getBoundingClientRect over ALL 8316 nodes:  5 ms   (one page.evaluate)
→ 61× on boundingBox alone

The real code does ~3 round-trips per node over the full tree, which is why it saturates the 5 s budget. Batching the whole tree into one page.evaluate is ~1000× fewer round-trips.

For reference, the individual DOM ops are all cheap (measured on the same page): full-DOM walk + getComputedStyle on all 8386 elements ≈ 24 ms; computedRole/computedName reads ≈ 1 ms; ariaSnapshot ≈ 55 ms; screenshot ≈ 75 ms. None of these are the cost — it is exclusively the per-node locator RPC fan-out.

Suggested fix

Resolve all node bounds in a single batched page.evaluate (a getBoundingClientRect pass over the resolved elements) instead of per-node locator round-trips. The role/name/nth → element resolution already exists batched in BATCH_VIEWPORT_CHECK_JS (used by computeElementVisibility for the annotation overlay) — so the same bounds are effectively computed twice today (once batched for the overlay, once per-node for the tree). Reuse the batched result for the tree.

Keep the existing per-node locator.boundingBox() only as a fallback for nodes whose batched DOM rect is zero-size — that's the Compose-Web-Wasm case the locator path exists for (accessibility overlay elements have zero-size DOM rects but valid positions in Playwright's a11y tree). On such pages the fallback set is small, so the fan-out is bounded.

Expected impact: view-hierarchy enrichment on heavy pages drops from ~5 s (budget-capped, partial bounds) to ~single-digit ms (complete bounds).

Repro

Capture screen state (any path that enriches the view hierarchy with bounds) on a large, dynamic results page and profile the daemon thread — enrichNodeWithLocatorBounds dominates. A ~1000+ node tree is enough to hit the 5 s budget cap.

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