Skip to content
Last updated: Sep 25, 2026

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).

bash
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–5005

build: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 kB

Notes:

  • preview:all serves real Vite production builds — a production-like local view. Do not measure vite 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, so build:all must run first.
  • The host binds port 10000, which is the same port the local blitz-ui Docker container uses. Stop that container first or they collide.
  • preview.proxy mirrors server.proxy in apps/ui/vite.config.ts, so /api/* reaches the local blitz-api container. Verify with curl -s -o /dev/null -w '%{http_code}' http://localhost:10000/api/version → 200.

Unauthenticated run (login page) ​

bash
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" --view

Unthrottled 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:

bash
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.

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.

js
// 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:

js
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 ​

bash
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:

bash
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 -15

For true module-level attribution you would need to set sourcemap: true, rebuild, and then run a visualizer.

Which measurement to run for which question ​

QuestionTool
Bundle size / initial loadLighthouse + build:all chunk warnings
Post-auth page costCold authenticated Lighthouse (above)
Responsiveness / input lagWarm-run TBT
API throughput, RPS, concurrencyk6 — see Load Testing
Workflow/batch throughputTemporal 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.

Finaisse Internal — Confidential. Access-restricted; not for external distribution.