Skip to content

rpc.slow: Base UI label lookup on every render saturates the macOS webview #106

Closed

Description

@mubeda

Summary

On the macOS desktop app, the UI froze for seconds at a time while the window was in use. One interaction took 26.5 s to paint, and replies that the server answered in under 0.1 ms took up to 10.4 s to reach the screen. The profiler raised rpc.slow for server.getProcessDiagnostics (now error), previewAutomation.focusHost and server.getProviderUsage, together with client.stall (#99) and client.interaction (#103). Outside the profiler, the WebKit WebContent process that runs the BiBCode UI was at 55 to 102 % CPU and 2.75 GB RSS (1015 MB physical footprint). A 5 s sample of that process puts about 73 % of the main thread in WebKit's LabelsNodeList traversal, which is what element.labels[i] does. In the web dependencies, only Base UI's useAriaLabelledBy reads .labels, and it reads it after every commit of every Switch, Checkbox and Radio.

Environment

Field Value
BiBCode 0.9.2, desktop (DMG)
OS macOS 27.0.1, arm64
Mode local
Finding rpc.slow-ee0e8d26aa17: error, seen 19 times from 2026-10-12T13:26:54Z to 2026-10-14T19:13:12Z
Fingerprint ee0e8d26aa17
Capture native dumps available, OS crash records available

Same incident, same client, also covered here:

  • rpc.slow-37b7a238df64, server.getProcessDiagnostics: warn, 13 times, last 2026-10-14T19:13:12Z
  • rpc.slow-d5be99ded9ca, previewAutomation.focusHost: warn, 2 times, last 2026-10-14T19:12:05Z
  • rpc.slow-006a9e582a40 and rpc.slow-e12cdb23a0d5, server.getProviderUsage: warn, 2 times each, last 2026-10-14T19:13:12Z

Evidence

  • 97f7ca61-5143 client.request server.getProcessDiagnostics: total 10,422 ms, server 0.07 ms, network 7,047 ms, render 3,375 ms. With the server in-process, most of that "network" time is likely the reply waiting for the busy main thread.
  • 97f7ca61-5145 client.request server.getProcessDiagnostics: total 3,309 ms, server 0.04 ms, render 2,667 ms.
  • 97f7ca61-5122 client.request previewAutomation.focusHost: total 9,610 ms, server 0.01 ms, render 9,086 ms.
  • 97f7ca61-5114, 97f7ca61-5148 client.request server.getProviderUsage: totals 2,290 and 1,091 ms, server 0.02 ms each, render 1,638 and 530 ms.
  • 97f7ca61-5150 client.batch (the client.stall finding): 6 stalls, 13,600 ms total, the longest 6,189 ms, in a 10,001 ms window, so not timer throttling (compare client.stall: heartbeat counts WebKit timer throttling as stalls on macOS #99).
  • 97f7ca61-5132 client.batch (the client.interaction finding): 4 slow interactions, the slowest 26,464 ms.
  • 97f7ca61-5156 process.sample: the desktop host (with the server in-process) at 0.5 % CPU. Its private memory went from 74 MB to 169 MB at about 19:11Z (97f7ca61-5108, -5135) and then stayed flat. The WebContent process does not appear in process.sample (see profiler: macOS desktop never samples its WebKit WebContent process #104).
  • RPC traffic in the 5 minutes before: 19 server.refreshProviderUsage, 15 server.getProcessDiagnostics, 4 server.getProviderUsage, 2 previewAutomation.focusHost.
  • sample of the WebContent process, 5 s at 1 ms: 1,980 samples on the main thread. 1,438 run from JIT JavaScript through operationGetByValGeneric → WebCore::JSNodeList::getOwnPropertySlotByIndex → WebCore::CachedLiveNodeList<WebCore::LabelsNodeList>::item → WebCore::CollectionTraversal<…>::begin<WebCore::LabelsNodeList>. That is an indexed read, labels[0], on a live labels list whose cache was invalidated, so WebKit walks the document to find the first label.
  • footprint of the WebContent process: 1015 MB, of which 647 MB is dirty WebKit Malloc.

Stack

WebContent main thread, top first (system frames, from sample):

#0 WebCore::CollectionTraversal<1>::begin<WebCore::LabelsNodeList>
#1 WebCore::CachedLiveNodeList<WebCore::LabelsNodeList, 1>::item(unsigned int)
#2 WebCore::JSNodeList::getOwnPropertySlotByIndex
#3 operationGetByValGeneric (JavaScriptCore)
#4 … JIT JavaScript (unsymbolicated)

Suspected source

  • @base-ui/react 1.7.0, internals/labelable-provider/useAriaLabelledBy.js: useIsoLayoutEffect without dependencies, so it runs after every commit. Unless the control has an explicit aria-labelledby or label id, it calls findAssociatedLabel, which ends in labelSource.labels and labels[0]. Used by SwitchRoot, CheckboxRoot, RadioRoot and OTPFieldRoot. A search of the web dependencies for .labels reads finds no other browser-side caller.
  • apps/web/src/components/ui/switch.tsx:7-28: the shared Switch passes no aria-labelledby, so every switch takes the fallback. The same goes for the ui/checkbox.tsx and ui/radio-group.tsx wrappers. <Switch, <Checkbox and <Radio appear 77 times across 33 files, with 11 in components/settings/SettingsPanels.tsx.
  • apps/web/src/components/status-bar/AppStatusBar.tsx:134: the 2 s server.getProcessDiagnostics poll, plus the provider-usage refresh. Each reply that commits under a mounted control repeats the label scan, so the cost is roughly polls × controls × document size.

Reproduction

  1. In the macOS desktop app with the Profiler on, open screens that mount many Base UI switches or checkboxes (Settings, especially with About → View diagnostics open), and keep a large conversation loaded so the document is big.
  2. Leave the window focused for a few minutes while the status-bar polls run.
  3. Watch ps -o %cpu,rss -p <WebContent pid> (the WebContent process started together with bibcode-desktop) and run sample <pid> 5. Expect LabelsNodeList traversal to dominate.
  4. Run profiler findings --since 10m --json. Expect rpc.slow findings with server time near 0 and large render or network times, plus client.stall.

Root-cause hypothesis

Base UI's label fallback reads the live labels list of every labelable control after every React commit. In WebKit, that list is invalidated by any DOM change, so each read walks the document. With dozens of controls mounted, a large DOM and commits driven by polling every 2 s, the main thread is busy all the time, and RPC replies queue behind it. The 2.75 GB RSS and 647 MB of dirty WebKit Malloc are not explained by this scan. They may be the macOS counterpart of #96's webview growth and should be tracked there. Confidence: high that labels[0] traversal dominates the main thread (direct sample); medium that Base UI's fallback is the caller (the only browser-side .labels reader among the dependencies, but the JavaScript frames are unsymbolicated); low on which mounted screen contributes most.

Suggested fix direction

  • Give every Base UI Switch, Checkbox, Radio and OTP field an explicit aria-labelledby, or wrap it in Base UI Field with a Field.Label, so labelId is set and the fallback never runs. Enforce this in the ui/ wrappers, for example by requiring an aria-label or aria-labelledby prop.
  • Report the per-commit .labels read to Base UI, which could run the fallback only when the DOM around the control changes.
  • Stop status-bar polls from committing when the data has not changed, and pause them while the window is hidden or occluded.
  • Confirm with a Web Inspector timeline on the desktop build after the fix: LabelsNodeList should be gone from sample, and WebContent CPU should be near idle.

Activity

  1. mubeda commented on Oct 14, 2026

    @mubeda
    OwnerAuthor

    New occurrence of rpc.slow-2cb2caf8ee75 (profiler fingerprint 2cb2caf8ee75), the same client and cause as this issue:

    Field Value
    BiBCode 0.9.2, desktop (DMG)
    OS macOS 27.0.1, arm64
    Count 3, last seen 2026-10-14T19:14:40Z

    What is new: a sixth fingerprint from the same incident. previewAutomation.focusHost took 2,012 ms (server 0 ms, network 1,676 ms, render 336 ms).

    Still active at 2026-10-14T21:25Z: the WebContent process is at 8 to 100 % CPU and 2.90 GB RSS (it was 2.75 GB at 19:14Z). A new 5 s sample again shows WebCore::LabelsNodeList traversal as the top non-idle frame (895 samples). client.stall-93202704f780 reached 589 and the error rpc.slow-ee0e8d26aa17 reached 34.

  2. mubeda commented on Oct 15, 2026

    @mubeda
    OwnerAuthor

    Shipped in v0.9.3 (678fc29).

    Base UI 1.9 skips the page-wide label lookup for controls that already have a label, and the file checkboxes in the commit dialog now have one. A regression test counts labels reads across 20 commits of every switch, checkbox and radio (80 reads before, 0 after).

  3. mubeda commented on Oct 27, 2026

    @mubeda
    OwnerAuthor

    New occurrence of rpc.slow-ee0e8d26aa17 (profiler fingerprint ee0e8d26aa17):

    Field Value
    BiBCode 0.12.0, macOS desktop (the fix here shipped in 0.9.3)
    OS macOS 27.0.1, arm64
    Count 36 (error), last seen 2026-10-27T20:50:59Z (35 at the previous report)

    What is new: the finding came back on 0.12.0 with a different slow part. Render took only 24 ms, so the label-lookup render saturation fixed above is not the cause. Almost all of the wait was network time.

    • Finding evidence: server.getProcessDiagnostics, total 1025 ms = server 0.17 ms + network 1001 ms + render 24 ms, slow part network, joined by correlation id (event 19a12444-688).
    • Context (low confidence): the delayed reply reached the client at 20:50:59.195Z, in the same client batch as a server.refreshProviderUsage reply. That call had just taken 15,148 ms on the server (rpc.timing, tier severe, event 19a12444-687, finished 20:50:58.154Z). The diagnostics reply may have waited behind the provider-usage completion on the shared connection or in the client's receive path. server.refreshProviderUsage is listed in is_long_running (apps/server/src/diagnostics/profiler/latency.rs:146), so its own duration raises no finding.
    • One occurrence so far on 0.12.0, so this is not yet a regression trend.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions