Admin sidebar startup performance
Reference document for the P3 backlog row "Al caricamento delle pagine in backoffice, la sidebar ha un ritardo notevole". It defines the measured scenarios, the metric contract, the reproducible harness, and the accepted evidence for the change from a client-constructed primary navigation to a server-rendered, hydrated one.
Related: doc/public/en/architecture/frontend-delivery.md (build policy and budgets),
template/default/AI.theme.md and template/sober/AI.theme.md (layout
contracts), doc/public/en/complete.md § admin shell.
What changed
Before the change both admin themes shipped an empty <ul> and built the whole
primary navigation in the browser from window.__MENU_DATA__ through an Alpine
x-for. The navigation therefore could not exist before the theme bundle had
been downloaded, parsed and executed, and it could not exist at all when
JavaScript was disabled or the bundle failed.
Now both layouts render the complete ACL-filtered navigation — links, labels, icons, active item, ancestor trail and the active expanded branch — in the HTML response, mark the desktop collapsed/expanded state before paint, and let Alpine hydrate the existing nodes by stable menu id. The critical entry no longer pulls Markdown/DOMPurify, Klaro, maps, masks or the editors; those load only on pages that declare them.
Metric contract
Browser marks published by the shell
(template/shared/asset/js/admin/performance.js plus the two layouts):
| Mark | Meaning |
|---|---|
k0smos:admin-response-start |
navigation responseStart |
k0smos:admin-first-contentful-paint |
FCP replayed as a mark |
k0smos:admin-dom-content-loaded |
domContentLoadedEventEnd |
k0smos:admin-shell-bootstrap-start |
theme entry begins executing |
k0smos:admin-sidebar-server-rendered |
server markup for the drawer is parsed |
k0smos:admin-sidebar-first-visible |
first frame in which the primary navigation holds usable controls |
k0smos:admin-sidebar-interactive |
the layout model finished hydrating |
k0smos:admin-shell-stable |
two animation frames after bootstrap completed |
Server samples are published as Server-Timing on authenticated admin
responses: k0smos-route, k0smos-view-bootstrap, k0smos-menu-collect,
k0smos-menu-acl, k0smos-menu-map, k0smos-template, k0smos-terminal.
The benchmark's two headline metrics are measured with one predicate that is valid for both the old and the new rendering model, so the comparison is not biased by instrumentation that only exists on one side:
- sidebar first visible — the first animation frame in which the primary navigation contains at least one real menu control. A streaming parse can satisfy this while the list is still growing, which is what a user actually sees.
- sidebar stable — the first animation frame in which the document has
finished parsing and that navigation carries its resolved active item
(
[data-menu-active="true"]). The readiness condition is what stops a partially parsed list from counting as the final state. This is the gate metric.
p95 is the nearest-rank 95th percentile. At the default sample size per cell
that resolves to the worst observed sample, which is the conservative reading
for an improvement claim.
When script execution is disabled or the entry bundle is blocked, the same two values are taken from the protocol-side First Contentful Paint if the response markup contains a usable navigation, and are reported as unreachable if it does not.
Reproducing the measurement
tools/benchmarks/admin-sidebar-latency.sh --variant current --iterations 10 --warmup 2
The runner:
- creates an isolated SQLite database and two throwaway tenant descriptors
(one per admin theme) through
tools/benchmarks/admin-sidebar-fixture.php; the fixture refuses any database path outsidevar/db/benchmark-*or the system temp directory, so a configured runtime database is unreachable; - seeds a restricted operator (
roles.readonly) next to the default administrator, which is what makes the minimal menu and full menu scenarios differ by authorization rather than by configuration; - serves the application twice with
php -Sand the benchmark router shim, one process per admin theme, inAPP_ENV=production; - drives a local Chromium over the DevTools protocol
(
tools/benchmarks/admin-sidebar-latency.mjs) across the scenario matrix; - removes the tenants, the database and the browser profile on exit.
A baseline run uses the same harness inside a disposable worktree of the pre-change commit:
git worktree add --detach /tmp/k0smos-p3-baseline <pre-change-commit>
cp -a vendor /tmp/k0smos-p3-baseline/vendor
ln -s "$PWD/template/default/node_modules" /tmp/k0smos-p3-baseline/template/default/node_modules
ln -s "$PWD/template/sober/node_modules" /tmp/k0smos-p3-baseline/template/sober/node_modules
( cd /tmp/k0smos-p3-baseline/template/default && npx vite build )
( cd /tmp/k0smos-p3-baseline/template/sober && npx vite build )
mkdir -p /tmp/k0smos-p3-baseline/tools/benchmarks
cp -r tools/benchmarks/. /tmp/k0smos-p3-baseline/tools/benchmarks/
ROOT_OVERRIDE=/tmp/k0smos-p3-baseline tools/benchmarks/admin-sidebar-latency.sh --variant baseline ...
vendor/ is copied rather than symlinked because Composer resolves its
autoload base directory relative to the vendor directory; the copy makes the
worktree load its own src/ and modules/.
Reports are written to reports/ (ignored by git). Numbers are hardware- and
load-dependent: compare a baseline and a current run taken back to back on the
same machine, never a stored number against a fresh run.
Scenario matrix
| Dimension | Values |
|---|---|
| Admin theme | default, sober |
| Device | desktop 1440×900, mobile 390×844 (DPR 3, touch) |
| Cache | cold (cleared, cache disabled), warm |
| JavaScript | enabled, disabled, entry bundle blocked ("failed") |
| Menu | full authorized (administrator), minimal (restricted role) |
| CPU/network | normal, throttled (4× CPU, ~1.6 Mbit/s, 150 ms latency) |
48 cells. The scripted cells keep the full cross product — 2 themes × 2 devices × 2 cache states × 2 menu sizes × 2 load profiles = 32 — because the p95 gate is decided on them. The two no-JavaScript resilience states run cold-cache with the full menu across both themes, both devices and both load profiles (16 cells): they answer whether the navigation is reachable at all, which cache state and menu size do not change. The harness prints every cell it runs, so a reduced run is never silently reported as full coverage.
Results
Measured on 2026-08-21 on one machine, both variants back to back, 10 iterations after 2 warm-ups per cell. Regenerate the tables with:
node tools/benchmarks/admin-sidebar-report.mjs \
--baseline reports/admin-sidebar-latency-baseline.json \
--current reports/admin-sidebar-latency-current.json \
--write doc/public/en/quality/admin-sidebar-performance.md
Verdict against the P3 gate
The gate was "improve sidebar-stable p95 by at least 30%, with no regression above 10% in server menu time or the Sober theme."
- Default — met. 15 of 16 scripted cells improve by 31–75%; the mean is
−54%. The single cell below the target is
default/desktop/warm/minimal-menu/normalat −24%: a three-item menu on a warm cache, where there was almost no client-side work left to remove. Where the reported problem actually lives — the full authorized menu — the improvement is 37–43% on a normal link and 51–63% on a throttled one; the largest gains overall (74–75%) are the throttled minimal-menu cells, where the old build still had to download and execute the whole entry before rendering three links. - Sober — no regression, and the 30% target does not apply. Sober's mean is −9% (best −22%, worst +3%). Its pre-change entry was already 71 KB, so it never carried the Default bundle's startup cost; the change adds hydration and the shared drawer helpers on top of a bundle that was close to optimal. The gate's own wording constrains Sober to "no regression above 10%", which holds: the worst cell is +3% and no cell regresses beyond that.
- Reachability — changed categorically. With JavaScript disabled or the entry bundle blocked, the baseline exposed zero navigation controls in both themes; the current build exposes the full 104-item authorized navigation. This is the part of the row no percentage captures.
- Server response — unchanged within noise. TTFB p95 moves by −22 ms to
+40 ms across cells with no consistent direction. The menu collection,
authorization and mapping code was not touched by this work, and its
Server-Timinginstrumentation ships with the change, so no pre-change server-menu sample exists to compare against; the absolute figures below are the new reference.
One caveat on the transferred-bytes column: php -S serves responses
uncompressed, so the server-rendered navigation looks far more expensive on the
wire than it is. The same admin document is 338 060 bytes raw and 22 031 bytes
gzipped; a deployment behind Caddy/FrankenPHP negotiates encoding normally.
Sidebar-stable latency, JavaScript enabled
p95 of the gate metric. Lower is better; the delta is current versus baseline.
| Scenario | Baseline p95 | Current p95 | Delta | Baseline p50 | Current p50 |
|---|---|---|---|---|---|
| default / desktop / cold / full / normal | 633 ms | 300 ms | -53% | 474 ms | 261 ms |
| default / desktop / cold / full / throttled | 8695 ms | 3306 ms | -62% | 8401 ms | 3143 ms |
| default / desktop / cold / minimal / normal | 355 ms | 245 ms | -31% | 304 ms | 211 ms |
| default / desktop / cold / minimal / throttled | 7425 ms | 1870 ms | -75% | 7271 ms | 1811 ms |
| default / desktop / warm / full / normal | 608 ms | 297 ms | -51% | 493 ms | 264 ms |
| default / desktop / warm / full / throttled | 8453 ms | 3202 ms | -62% | 8259 ms | 3121 ms |
| default / desktop / warm / minimal / normal | 372 ms | 281 ms | -24% | 321 ms | 192 ms |
| default / desktop / warm / minimal / throttled | 7341 ms | 1860 ms | -75% | 7264 ms | 1829 ms |
| default / mobile / cold / full / normal | 490 ms | 309 ms | -37% | 460 ms | 255 ms |
| default / mobile / cold / full / throttled | 8662 ms | 3225 ms | -63% | 8149 ms | 3140 ms |
| default / mobile / cold / minimal / normal | 370 ms | 223 ms | -40% | 296 ms | 176 ms |
| default / mobile / cold / minimal / throttled | 7327 ms | 1843 ms | -75% | 7254 ms | 1792 ms |
| default / mobile / warm / full / normal | 556 ms | 319 ms | -43% | 460 ms | 261 ms |
| default / mobile / warm / full / throttled | 8448 ms | 3190 ms | -62% | 8217 ms | 3138 ms |
| default / mobile / warm / minimal / normal | 390 ms | 221 ms | -43% | 336 ms | 189 ms |
| default / mobile / warm / minimal / throttled | 7371 ms | 1936 ms | -74% | 7219 ms | 1806 ms |
| sober / desktop / cold / full / normal | 476 ms | 378 ms | -20% | 400 ms | 305 ms |
| sober / desktop / cold / full / throttled | 2184 ms | 1843 ms | -16% | 2027 ms | 1802 ms |
| sober / desktop / cold / minimal / normal | 256 ms | 253 ms | -1% | 211 ms | 200 ms |
| sober / desktop / cold / minimal / throttled | 1328 ms | 1168 ms | -12% | 1256 ms | 1102 ms |
| sober / desktop / warm / full / normal | 414 ms | 426 ms | +3% | 358 ms | 342 ms |
| sober / desktop / warm / full / throttled | 2286 ms | 1833 ms | -20% | 2035 ms | 1797 ms |
| sober / desktop / warm / minimal / normal | 243 ms | 246 ms | +1% | 205 ms | 218 ms |
| sober / desktop / warm / minimal / throttled | 1285 ms | 1155 ms | -10% | 1234 ms | 1107 ms |
| sober / mobile / cold / full / normal | 396 ms | 360 ms | -9% | 368 ms | 329 ms |
| sober / mobile / cold / full / throttled | 2072 ms | 1854 ms | -11% | 1985 ms | 1796 ms |
| sober / mobile / cold / minimal / normal | 253 ms | 261 ms | +3% | 208 ms | 195 ms |
| sober / mobile / cold / minimal / throttled | 1345 ms | 1239 ms | -8% | 1232 ms | 1094 ms |
| sober / mobile / warm / full / normal | 405 ms | 410 ms | +1% | 342 ms | 302 ms |
| sober / mobile / warm / full / throttled | 2367 ms | 1835 ms | -22% | 2022 ms | 1791 ms |
| sober / mobile / warm / minimal / normal | 269 ms | 262 ms | -3% | 216 ms | 198 ms |
| sober / mobile / warm / minimal / throttled | 1286 ms | 1109 ms | -14% | 1237 ms | 1094 ms |
| Theme | Cells | Mean p95 change | Best | Worst | Cells below −30% |
|---|---|---|---|---|---|
| default | 16 | -54% | -75% | -24% | 1 |
| sober | 16 | -9% | -22% | 3% | 16 |
defaultcells below the −30% target:default/desktop/warm/js-enabled/minimal-menu/normal(-24%).sobercells below the −30% target:sober/desktop/cold/js-enabled/full-menu/normal(-20%),sober/desktop/cold/js-enabled/full-menu/throttled(-16%),sober/desktop/cold/js-enabled/minimal-menu/normal(-1%),sober/desktop/cold/js-enabled/minimal-menu/throttled(-12%),sober/desktop/warm/js-enabled/full-menu/normal(3%),sober/desktop/warm/js-enabled/full-menu/throttled(-20%),sober/desktop/warm/js-enabled/minimal-menu/normal(1%),sober/desktop/warm/js-enabled/minimal-menu/throttled(-10%),sober/mobile/cold/js-enabled/full-menu/normal(-9%),sober/mobile/cold/js-enabled/full-menu/throttled(-11%),sober/mobile/cold/js-enabled/minimal-menu/normal(3%),sober/mobile/cold/js-enabled/minimal-menu/throttled(-8%),sober/mobile/warm/js-enabled/full-menu/normal(1%),sober/mobile/warm/js-enabled/full-menu/throttled(-22%),sober/mobile/warm/js-enabled/minimal-menu/normal(-3%),sober/mobile/warm/js-enabled/minimal-menu/throttled(-14%).- No compared cell regressed by more than 10%.
Navigation reachability without a working bundle
Median number of usable menu controls in the primary navigation.
| Theme | JavaScript | Baseline links | Current links |
|---|---|---|---|
| default | disabled | 0–0 | 104–104 |
| default | failed | 0–0 | 104–104 |
| sober | disabled | 0–0 | 104–104 |
| sober | failed | 0–0 | 104–104 |
Server response and transferred bytes
Time to first byte is the only server-side figure measurable in both variants: the Server-Timing instrumentation ships with the change, so the baseline publishes none. Transferred bytes are uncompressed — the benchmark server does not negotiate content encoding.
| Scenario | TTFB p95 (baseline → current) | Server menu p95 | Server template p95 | Transferred p50 (baseline → current) |
|---|---|---|---|---|
| default / desktop / cold / full / normal | 203 ms → 217 ms | 76 ms | 15 ms | 1434 KB → 727 KB |
| default / desktop / cold / minimal / normal | 171 ms → 160 ms | 54 ms | 7 ms | 1375 KB → 442 KB |
| default / mobile / cold / full / normal | 159 ms → 199 ms | 67 ms | 13 ms | 1434 KB → 727 KB |
| default / mobile / cold / minimal / normal | 161 ms → 139 ms | 42 ms | 6 ms | 1375 KB → 442 KB |
| sober / desktop / cold / full / normal | 212 ms → 219 ms | 78 ms | 15 ms | 270 KB → 442 KB |
| sober / desktop / cold / minimal / normal | 169 ms → 176 ms | 57 ms | 7 ms | 217 KB → 228 KB |
| sober / mobile / cold / full / normal | 173 ms → 211 ms | 70 ms | 12 ms | 270 KB → 442 KB |
| sober / mobile / cold / minimal / normal | 171 ms → 173 ms | 60 ms | 7 ms | 217 KB → 228 KB |
Baseline run: 2026-08-21T13:17:21.885Z, 10 iterations after 2 warm-ups. Current run: 2026-08-21T12:15:30.757Z, 10 iterations after 2 warm-ups.
Build budgets
Both themes are built by the maintainer with npm run build:<theme>, which runs
the shared production policy and scripts/check-theme-asset-budget.mjs against
config/quality/frontend-asset-budgets.json.
Critical entry, measured on the produced artifacts (gzip -9):
| Theme | Baseline raw | Current raw | Baseline gzip | Current gzip | Raw budget | Gzip budget |
|---|---|---|---|---|---|---|
default |
639 694 B | 91 270 B (−85.7%) | 195 239 B | 29 909 B (−84.7%) | 360 000 B | 120 000 B |
sober |
71 203 B | 76 343 B (+7.2%) | 24 299 B | 25 732 B (+5.9%) | 90 000 B | 32 000 B |
The Default reduction comes from moving Markdown/DOMPurify, Klaro, maps, masks,
storefront search and the editors behind dynamic imports; the budget file lists
them as forbidden_eager_sources and required_dynamic_sources, so the check
fails if any of them returns to the critical entry.
Sober grows slightly because it now carries the hydration model and the shared drawer accessibility helpers. The increase is inside its budget and inside the 10% regression tolerance the P3 gate sets for the Sober theme.
Visual evidence
tools/benchmarks/admin-sidebar-screenshots.mjs captures five reviewable shell
states per theme against the same benchmark servers — desktop expanded, desktop
collapsed, mobile drawer closed, mobile drawer open, and mobile with script
execution disabled — using the shell's own toggle rather than a synthetic state
change. Output lands in reports/ (ignored by git). The captures support the
maintainer's visual review; they do not replace it.
Behavioural regressions covered by tests
| Behaviour | Test |
|---|---|
Server HTML carries the authorized navigation, no x-cloak on it |
tests/Integration/Theme/AdminSidebarMarkupTest.php |
| ACL filtering shrinks the navigation for a restricted role | tests/Integration/Theme/AdminSidebarMarkupTest.php |
| Deep route marks the active node and expands its branch server-side | tests/Integration/Theme/AdminSidebarMarkupTest.php |
| Sober renders the same contract | tests/Integration/Theme/AdminSidebarMarkupTest.php |
Server-Timing samples reach the client |
tests/Integration/Theme/AdminSidebarMarkupTest.php |
| Markup/model/CSS accessibility contract | tests/Unit/Theme/AdminSidebarServerRenderingContractTest.php |
| Mobile drawer hidden state, focus move/restore, Escape, Tab containment, breakpoint changes, active/expanded state, reduced motion | template/default/test/admin-sidebar-interaction.test.js |
| Same for the sober drawer | template/sober/test/sober-sidebar-interaction.test.js |
| Tooltip hover intent, pinning, keyboard focus | template/default/test/tooltip.test.js, template/sober/test/sober-admin.test.js |
Rollback position
A temporary compatibility flag was considered, in case markup hydration broke
deployed custom themes. It is deliberately not implemented: default and
sober are the only themes that own an admin layout
(template/*/tpl/admin.tpl.php), and the admin template resolver falls back to
default for every other theme, so a public custom theme cannot inherit the
changed drawer markup. Adding a second rendering path would have meant keeping
a client-only navigation alive — exactly the code this row removed. If a future
theme does claim an admin layout, the contract to satisfy is the one in this
document, not a flag.
Suite status at delivery
vendor/bin/phpunit --testsuite Application reports 2642 tests with 15 failures
and 1 error. Every one of them reproduces with this work stashed and belongs to
areas it never touches — Psapi/Wpapi admin routes, Mail and Payment settings
APIs, the Robo/Tracy evaluation contracts, an Ecommerce read-model DTO, the cc1
Klaro script order, and a SoberAdminThemeCompatibilityTest assertion still
expecting the pre-policy entryFileNames: 'js/[name].js' literal that the
frontend delivery policy replaced. They are recorded here so a later reader does
not attribute them to the sidebar work; fixing them belongs to their own owners.
Rules for future changes
- Never rebuild the primary navigation client-side, and never hide the whole
sidebar behind
x-cloak; the first response must be usable on its own. inert/aria-hiddenon the drawer are runtime state only. Server markup that shipped them would strand the navigation when the bundle fails.- Keep the CSS
visibilityguard on the closed mobile drawer: it removes the off-canvas navigation from the tab order before hydration, and the<noscript>block must keep overriding it. - Menu authorization output is per user and per tenant and must never be cached across either.
- Budgets move only with a measured bundle-composition explanation, recorded with the change review.