Measuring Frontend Performance
How to reproduce the numbers in the Performance baseline. Everything here runs locally and natively — no Railway spend, no scale-up.
Tooling — free, and no Docker
Lighthouse is free and open source (Apache 2.0, Google). No licence key, no account, no paid tier. @lhci/cli (Lighthouse CI) is likewise free. The only paid thing nearby is PageSpeed Insights API quota at high volume, which is a hosted convenience wrapper you don't need.
Do not run Lighthouse in Docker on Apple Silicon
Most published Lighthouse/Chrome images are amd64-only and would run under QEMU emulation on an arm64 Mac. For a tool whose entire output is timing measurements, emulated Chrome doesn't just run slow — it makes the numbers meaningless.
Run it natively against /Applications/Google Chrome.app (a native arm64 build). This is the fastest and most accurate option, not a compromise. The arm64 concern is Docker-only; GitHub's ubuntu-latest runners are amd64 natively, so CI has no emulation problem.
Build and serve
The real package root is blitz-ui/src/, not the repo root (which has no package.json).
cd blitz-ui/src
bun run build:all # builds 5 remotes + host, sequentially (~several minutes)
bun run preview:all # host on http://localhost:10000, remotes on 5001–5005build:all is where the bundle warning surfaces:
(!) Some chunks are larger than 5000 kB after minification.
dist/assets/index-42wuwHUI.js 8,360.59 kB │ gzip: 2,959.18 kBNotes:
preview:allserves real Vite production builds — a production-like local view. Do not measurevite dev; dev builds are unoptimized and will look far worse than production for reasons that aren't real.- It fails early if any
dist/is missing, sobuild:allmust run first. - The host binds port 10000, which is the same port the local
blitz-uiDocker container uses. Stop that container first or they collide. preview.proxymirrorsserver.proxyinapps/ui/vite.config.ts, so/api/*reaches the localblitz-apicontainer. Verify withcurl -s -o /dev/null -w '%{http_code}' http://localhost:10000/api/version→200.
Unauthenticated run (login page)
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
bunx --bun lighthouse http://localhost:10000/ \
--only-categories=performance \
--output=html --output-path=./lh-baseline \
--chrome-flags="--headless=new --no-sandbox" --viewUnthrottled run (what a fast desktop actually sees)
Lighthouse's default throttling answers "how bad is this on a mid-range phone on 4G." To answer "what does a user on a good machine see," disable it:
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
bunx --bun lighthouse http://localhost:10000/ \
--only-categories=performance \
--preset=desktop --throttling-method=provided \
--output=json --output-path=./lh-desktop-real.report.json \
--chrome-flags="--headless=new --no-sandbox"--throttling-method=provided means "use whatever the environment really is" — no CPU or network simulation. Run both and report both; each answers a different question, and quoting only one is how these numbers get misused in either direction.
--output-path needs the full filename when emitting JSON only
With a single --output=json, Lighthouse writes exactly the path given. Passing --output-path=./name (no extension) produces an extensionless file and any follow-up script reading name.report.json fails. Pass the complete filename, or use --output=json --output=html (which appends .report.json / .report.html).
Useful extra audits in the unthrottled report — bootup-time (JS parse/execute, 0.2 s in the 2026-08-19 run, which is what proved the throttled figure was network-bound rather than CPU-bound) and main-thread-tasks.
Neither run includes real transfer time
Both are served from localhost. To capture download cost you need either a deployed target or DevTools network throttling. The per-connection table on the baseline page is modelled arithmetic over the measured render time — clearly labelled as such, and worth replacing with real figures when a staging run is possible.
Authenticated runs
The tenant UI is behind a login, so a bare URL only ever measures the login page. Two things make this non-obvious.
Tenant resolution is by Host header
blitz-api resolves the tenant from the Host header (src/libs/tenancy/src/plugins/tenantInfoResolver.ts). Plain localhost returns {"error":"invalid tenant info"}. Local tenants are default.localhost, demo1-app.localhost, demo2-app.localhost, demo3-app.localhost.
The preview proxy already sends Host: default.localhost, so requests through localhost:10000/api/* resolve correctly. Hitting blitz-api:10001 directly requires -H "Host: default.localhost". There is also an X-Tenant-Id header path that bypasses domain resolution entirely.
Auth is cookie-based, so --extra-headers will not work
The router guard checks useCookie('userData') and useCookie('accessToken') (packages/shared/src/plugins/1.router/guards.ts). A Bearer header alone is ignored — you need cookies, which means Puppeteer.
Gotcha 1 — do not hand-set the cookies
useCookie (packages/shared/src/@core/composable/useCookie.ts) serializes via cookie-es's serialize, which already URI-encodes. Setting document.cookie yourself with encodeURIComponent double-encodes; the guard then silently redirects to /login with no error.
Drive the real login form instead. It avoids the encoding question entirely and also picks up loadAbilities (CASL permissions) in localStorage, which the guard depends on.
Warm run (simple, but misleading)
Log in via the form, then run Lighthouse over the same browser session with disableStorageReset: true to keep the cookies alive.
This preserves the HTTP cache too
The entry chunk is already downloaded and parsed during login, so warm runs measure only incremental route chunks (14–120 KiB) and report flattering FCP/LCP. Useful for TBT only — that metric caching does not flatter. Never quote warm FCP/LCP as a load time.
Cold run (comparable to a first-time visitor)
Harvest cookies + localStorage from one real form login, then for each target: fresh browser, seed auth before navigating, clear the network cache via CDP.
// one-time: harvest session from a real form login
const cookies = await bp.cookies();
const ls = await bp.evaluate(() => Object.fromEntries(Object.entries(localStorage)));
// per target: fresh browser => empty HTTP cache
await page.setCookie(...cookies.map(c => ({ ...c, url: BASE })));
// Seed localStorage via an intercepted HTML doc on the origin.
// NOT favicon.ico — localStorage is unreachable from a non-HTML document
// ("SecurityError: Access is denied for this document").
await page.setRequestInterception(true);
page.on('request', req => req.url() === `${BASE}/__lh_seed`
? req.respond({ status: 200, contentType: 'text/html', body: '<html><body>seed</body></html>' })
: req.continue());
await page.goto(`${BASE}/__lh_seed`, { waitUntil: 'domcontentloaded' });
await page.evaluate(d => { for (const [k, v] of Object.entries(d)) localStorage.setItem(k, v); }, ls);
// disableStorageReset keeps the session but preserves the cache — clear it explicitly
const cdp = await page.target().createCDPSession();
await cdp.send('Network.clearBrowserCache');
const r = await lighthouse(`${BASE}${path}`, {
port: 9222, onlyCategories: ['performance'],
disableStorageReset: true, clearStorageTypes: [],
});Gotcha 2 — verify finalDisplayedUrl on every report
If auth seeding fails, Lighthouse happily measures the /login redirect and reports a plausible-looking score for the wrong page. Always assert the final URL is the intended route, and treat identical metrics across routes as something to verify rather than assume:
if (r.lhr.finalDisplayedUrl.includes('/login')) throw new Error('auth seeding failed');In the 2026-08-19 baseline, /recon and /invoicehub genuinely produced identical numbers — confirmed by checking both URLs, then explained (both are thin shells dominated by the shared entry chunk).
Extracting metrics from a report
python3 -c "
import json; d=json.load(open('lh-baseline.report.json'))
print('URL:', d['finalDisplayedUrl'], '| score', round(d['categories']['performance']['score']*100))
for k in ['first-contentful-paint','largest-contentful-paint','total-blocking-time',
'cumulative-layout-shift','total-byte-weight','unused-javascript']:
a = d['audits'].get(k)
if a: print('%-28s %s' % (k, a.get('displayValue')))
"Analysing a bundle without a visualizer
apps/ui/vite.config.ts sets sourcemap: false, so rollup-plugin-visualizer would show chunk sizes but not attribute them to modules — and adding it means a dependency plus a full rebuild. Marker frequency on the built chunk is faster and was sufficient to identify the cause:
cd blitz-ui/src/apps/ui/dist/assets
grep -oiE "[a-z0-9_]*pdf[a-z0-9_]*" index-*.js | sort | uniq -c | sort -rn | head -20
grep -oE "ej2-[a-z-]+" index-*.js | sort | uniq -c | sort -rn | head -15For true module-level attribution you would need to set sourcemap: true, rebuild, and then run a visualizer.
Which measurement to run for which question
| Question | Tool |
|---|---|
| Bundle size / initial load | Lighthouse + build:all chunk warnings |
| Post-auth page cost | Cold authenticated Lighthouse (above) |
| Responsiveness / input lag | Warm-run TBT |
| API throughput, RPS, concurrency | k6 — see Load Testing |
| Workflow/batch throughput | Temporal scale-test scripts — see Load Testing |
Environment recorded with the baseline
Lighthouse 13.4.1, native arm64 Chrome (headless), Bun 1.3.11, macOS Darwin 25.5.0, local production build, default mobile throttling. Record the Lighthouse version with any future numbers — scoring weights change between major versions, which makes cross-version comparisons unreliable.