Skip to content

perf: xum server first load spends ~160 ms evaluating the entry module graph (zod schema construction) #6071

Closed

Description

@ThomasK33

Problem

On the first load of xum server in a browser, one main-thread task in main-*.js takes about 160 ms on desktop. Lighthouse reports it as the largest Total Blocking Time (TBT) contributor. The task is the browser evaluating the entry ES module graph. It is not React render or oRPC work.

About half of the task (95.6 of 190 ms in a profiled run) is zod schema construction at import time.

Why Lighthouse shows two "modes"

The Lighthouse auditor sees the task at either ~80 ms ("fast") or ~160 ms ("slow"). This is a Lantern simulation artifact. Lantern counts a task that contains a Layout at x0.5. The Layout comes from the index.html "Loading Xum..." ellipsis animation. The slow value is the real cost.

Plan

  1. perf: update zod to 4.6.5: lockfile plus a package.json floor of ^4.6.5. Measured eval median 160 -> 93 ms. Measured desktop TBT 111 -> 49.5 ms, mobile 659 -> 454 ms. This PR needs a changelog audit (4.4.3 -> 4.6.5) and a check that provider-facing tool JSON Schemas and oRPC schemas do not change, or that each change is explained.
  2. perf: import schema values from leaf modules, not the orpc barrel: 6 browser imports of @/common/orpc/schemas pull schemas/api.ts into the main chunk. Replace them with leaf imports and add an ESLint guard. Measured eval median 93 -> 77 ms.

Gate

  1. Local: interleaved base/head Lighthouse blocks. Head eval median at least 50 ms below base (PR 1) and 10 ms below (PR 2). Every head run under 120 ms desktop and 480 ms mobile.
  2. Post-merge: at least 5 auditor cycles. The longest main-*.js task stays under 120 ms desktop and 480 ms mobile in every qualified run. Host qualification (CPU pressure, load) is declared from an A/A run before the candidate runs. A run that misses with clean host data fails the gate.
  3. Desktop cold start A/B (scripts/perf/desktopColdStart.ts, 20 pairs): one-sided upper bound at most +5%.

Related: #5971 (first-load bytes).


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

Activity

  1. self-assigned this
    on Oct 10, 2026
  2. ThomasK33 commented on Oct 10, 2026

    @ThomasK33
    MemberAuthor

    zod 4.6.5 is on main: #6078 merged as 40de096e6b, after its prerequisites #6080 and #6087.

    • In the Lighthouse branch runs (3 heads, 12 runs each), the longest startup task was under its limit in every run: desktop 77–90 ms, mobile 306–354 ms. On main, all 12 runs were over.
    • D2 on the final head (run 38086092991): mean −5.17%, upper bound −4.48%.
    • First-run mobile Performance: 65–67, against 54–56 on main.

    This issue stays open until the post-merge gate passes: 5 auditor main cycles in which every host-qualified run is under 120 ms desktop and 480 ms mobile. Next after that: the leaf imports that replace the @/common/orpc/schemas barrel (plan estimate: 77 ms). Follow-ups: #6091.

  3. ThomasK33 commented on Oct 11, 2026

    @ThomasK33
    MemberAuthor

    Post-merge gate passed. I am closing this issue.

    After #6078 (zod 4.6.5, 40de096e6b), the longest main-*.js startup task stayed under its limit in every host-qualified Lighthouse run in 5 main cycles. I read each cycle's record from the auditor's run history myself.

    Cycle Run Main Max mobile (ms, limit 480) Max desktop (ms, limit 120)
    1 20261010T214414Z 40de096 377 84
    2 20261010T223837Z 40de096 356 83
    3 20261010T231346Z ccdd3dc 346 86
    4 20261011T000732Z ccdd3dc 414 85
    5 20261011T010229Z 9224e24 357 81
    • 60 of 60 host-qualified runs passed. Before the bump, 12 of 12 runs were over on main (mobile 574–674 ms, desktop 140–170 ms).
    • The host was under heavy load in cycle 4 (1-minute load average 16–61 on 32 CPUs). Mobile still had 66 ms of margin there. Desktop's smallest margin was 34 ms.
    • Cycles 2 and 4 measured an unchanged main again. The gate I set before any results did not require new commits.
    • Lighthouse first-run mobile Performance moved from 54–56 to 62–65, and seeded mobile from 49–52 to 56–57. Desktop is 94–96.

    Not done here: the planned second step, replacing imports of the @/common/orpc/schemas barrel with leaf imports (plan estimate: the task drops to about 77 ms). The task already meets its limit, so this step is optional. I track it with the mobile work instead of keeping this issue open. Follow-ups of the bump are in #6091.


    Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $704.04

  4. ThomasK33 commented on Oct 11, 2026

    @ThomasK33
    MemberAuthor

    Post-gate observation (perf owner). This issue stays closed.

    In auditor main cycle 20261011T113213Z (main 90974719ff, setup v2), one host-qualified run went over the startup-task limit. Seeded mobile run 3 had its longest main-*.js task at 649 ms (limit 480). I read that run's report file myself:

    Seeded mobile run Longest main task TBT CLS Lighthouse benchmarkIndex
    1 254 ms 359 ms 0.001 2,834
    2 279 ms 408 ms 0.001 3,122
    3 649 ms 1,114 ms 0.001 2,050

    The other 11 runs of that cycle stayed under the limits (mobile 254–322 ms, desktop 64–73 ms). Run 3's CPU benchmark was about 30% below runs 1–2 (Lighthouse then applies its 4x throttle on top). The auditor's retry floor is 2,000, so this run counted as qualified. I read it as a slow-host outlier, not a product regression. Nothing in the startup path changed in this commit range.

    Reopen trigger: reopen this issue if a second host-qualified run goes over the limit within the next 10 main cycles, or if any over-limit run has a benchmarkIndex of 2,500 or more. The gate rule itself is unchanged. I am not amending it after the fact.


    Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $752.79

  5. ThomasK33 commented on Oct 11, 2026

    @ThomasK33
    MemberAuthor

    Post-gate watch ended clean. No qualified run went over the limit in the 10 main cycles after the 649 ms outlier in run 20261011T113213Z, so the reopen trigger did not fire. This issue stays closed.

    Limits: the longest main-*.js task must stay under 120 ms on desktop and 480 ms on mobile. Each cycle is 12 Lighthouse runs (setup v2-sw-disabled).

    Run Main commit Max desktop task (ms) Max mobile task (ms) Min benchmarkIndex
    20261011T120607Z b9a5725 76 291 2786*
    20261011T124043Z 529ff4d 80 296 2750
    20261011T135008Z 5780d91 74 307 2886
    20261011T145858Z 4c67a93 72 272 2940
    20261011T154202Z 4cc70c3 71 279 2936
    20261011T164122Z 503163b 118 334 2571
    20261011T172124Z d892304 72 289 2613
    20261011T175656Z a279809 71 281 2701
    20261011T183045Z e3131d9 76 317 2854
    20261011T190449Z 8d3a381 72 270 2490

    * The report folder of the first cycle was pruned by the auditor's run script, which kept only the 10 newest run folders. Its task durations come from the history row that the script wrote from the report files at cycle time (states.<page>.<preset>.attribution[].main_task_ms). All 12 runs were host-qualified. The script no longer prunes run folders. I read the other 9 cycles from their report files.

    One run had a benchmarkIndex below 2,500: seeded mobile in 20261011T190449Z, at 2,490. Its longest task was 255 ms, under the mobile limit.


    Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $772.64

  6. ThomasK33 commented on Oct 12, 2026

    @ThomasK33
    MemberAuthor

    Reopening under the reopen trigger I declared ("an over-limit run with benchmarkIndex ≥ 2500"). The trigger fired, and I am not changing it after the number.

    The run: auditor run 20261012T112644Z on main d8defc5d67. In the first-run page, mobile, run 2, the longest first-party task was 1,152 ms (API-*.js, limit 480 ms), with benchmarkIndex 2,840.

    Why I think it is host contention (not verified):

    A correction to my earlier record on this issue:

    • What I called "host-qualified" runs only means the measurement lock was acquired without a timeout. The auditor's load guard waits up to 5 minutes for load below 0.75 × nproc, and then runs anyway. So "qualified" never excluded a loaded host.
    • 24 of 420 runs since the gate started began with load ≥ 24 or PSI ≥ 20.
    • The gate result still stands: every gate run was under the limit, including the loaded ones. Load can only add time.

    Close condition, declared now and before any new number:

    • Re-run the first-run mobile cell 6 times on current main.
    • Each run must start and end on a quiet host: loadavg-1 < 24 and PSI some avg10 < 20, both at start and at end. A run that fails this is discarded and retried, at most 12 tries in total.
    • If all 6 quiet runs stay under 480 ms, I close this issue as host contention.
    • If any quiet run goes over 480 ms, this is a regression and I investigate it here.

    Going forward: a run counts toward any startup-task verdict only if it meets that quiet-host condition. Runs that fail it are still recorded, but they are marked unqualified.


    Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $826.43

  7. ThomasK33 commented on Oct 12, 2026

    @ThomasK33
    MemberAuthor

    Closing as host contention, under the close condition declared above. I read the reports myself.

    The quiet-host re-check ran on main d8defc5d67, the same commit as the over-limit run. It is in the auditor's folder runs-quiet/20261012T115151Z. Setup: first-run page, mobile, Lighthouse 13.5.0, setup v2, service worker off in every try.

    Try Longest first-party task Chunk benchmarkIndex Start load / PSI End load / PSI
    1 151 ms API 3,023 9.19 / 1.88 8.61 / 0.84
    2 154 ms API 2,974 8.61 / 0.69 8.29 / 0.31
    3 171 ms API 2,925 8.29 / 0.31 8.09 / 0.13
    4 150 ms API 2,976 8.09 / 0.13 7.53 / 0.05
    5 168 ms API 3,084 7.53 / 0.05 7.17 / 0.02
    6 158 ms API 3,162 7.17 / 0.02 7.50 / 0.00

    All 6 counted tries were on a quiet host at start and at end (load < 24, PSI < 20), and all stayed under 480 ms. The maximum was 171 ms. So the 1,152 ms run was host contention (load 68, PSI 81).

    Rule change, in effect from now on: the auditor now records a quiet_host flag for every run (start and end both quiet). Only runs with that flag can trigger the over-limit rule. The load guard no longer runs on a loaded host: it records the slot as skipped instead. The reopen trigger stays as declared, now evaluated on quiet-host runs only.


    Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $832.91

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

Metadata

Metadata

Assignees

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