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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions