Files
apf_portal/docs/decisions/0017-performance-budgets-lighthouse-ci.md
T
Julien Gautier 1a20490320
CI / commits (pull_request) Successful in 2m27s
CI / check (pull_request) Successful in 2m33s
CI / scan (pull_request) Successful in 2m42s
CI / a11y (pull_request) Successful in 1m58s
CI / perf (pull_request) Successful in 5m37s
fix(ci): adapt gzip-budget check for the @angular/localize multi-bundle layout
The original commit on this branch (9ed74ca, then cherry-picked as
f776ce7) predated ADR-0019 / PR #91, which switched the portal-shell
build to `@angular/localize` and started emitting one self-contained
bundle per locale under `dist/apps/portal-shell/browser/<locale>/`.
The script looked for `index.html` directly under the dist root and
failed with ENOENT on every modern build.

Fix

- `scripts/check-gzip-budgets.mjs`:
  - Auto-detects layout: if `dist/index.html` exists → flat mode
    (unchanged behaviour). Otherwise enumerate immediate
    subdirectories with their own `index.html` and run the check
    per locale.
  - Budget thresholds apply *per locale* — each bundle is what the
    user's browser actually downloads. Aggregating across locales
    would understate the worst case.
  - Violations across locales are collected and reported together
    so a single CI run surfaces every breach; the locale tag is
    prefixed to each violation message for clarity.

- `docs/decisions/0017-...md` §Confirmation: spell out the per-locale
  check + cite ADR-0019 so a future reader of either ADR sees the
  connection.

Verified locally on the production build:
- 2 locale bundles detected (en, fr).
- Initial JS total: ~141 KB / 300 KB budget ✓ (each locale).
- CSS total: 4.62 KB / 150 KB budget ✓ (each locale).
- Largest lazy chunk: 1.55 KB / 100 KB per-chunk budget ✓.
2026-05-14 16:21:10 +02:00

21 KiB
Raw Blame History

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-04-30 R&D Lead
performance
frontend
backend
process

Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs

Context and Problem Statement

The portal is a CSR Angular SPA (ADR-0004) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context (ADR-0016) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.

We need to fix:

  • which metrics we track;
  • which thresholds bound them;
  • which tooling enforces them in CI and in production;
  • which gates block merges vs. surface warnings;
  • how BFF latency is bounded and observed.

This ADR fixes the framework. Concrete optimisation work happens at feature delivery time; this ADR ensures the bar exists and is enforced.

Decision Drivers

  • CSR-only (ADR-0004) — perceived perf is entirely client-side load + execution.
  • a11y context (ADR-0016) — perf failure modes hurt the APF user base disproportionately.
  • Industry-standard tooling, anti-bricolage.
  • Same observability stack as ADR-0012 — no parallel telemetry path.
  • Cohérence with ADR-0015 — the perf gate slot was reserved for this ADR.

Considered Options

Metrics scope

  • Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size. (Chosen.)
  • Custom metrics only (rejected — premature, not benchmarkable across the industry).
  • Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).

Front-end tooling

  • Lighthouse CI (@lhci/cli) for full audit + score, plus Angular budgets for bundle size enforcement at build. (Chosen.)
  • WebPageTest API.
  • Calibre, Speedlify (à la pointe but less mature for CI integration).

Threshold values

  • Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90. (Chosen.)
  • Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
  • Looser (matches "Needs Improvement") — rejected as insufficient.

Bundle budgets

  • Angular budgets in project.json, type error, blocking the build on overshoot. (Chosen.)
  • No budget (rejected — invariably leads to silent bloat).

Back-end SLOs

  • Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by ADR-0012), enforced at review and in scheduled reports. (Chosen.)
  • No back-end perf budget.

Real-user monitoring (RUM)

  • None in v1 — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
  • Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).

Decision Outcome

Metrics and thresholds (front-end)

Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:

Metric Threshold Source
LCP (Largest Contentful Paint) 2.5 s https://web.dev/lcp/
INP (Interaction to Next Paint) 200 ms https://web.dev/inp/ — replaces FID since March 2024
CLS (Cumulative Layout Shift) 0.1 https://web.dev/cls/
TBT (Total Blocking Time, lab proxy of INP) 200 ms https://web.dev/tbt/
TTFB (Time to First Byte) 800 ms https://web.dev/ttfb/
Lighthouse Performance score 90 on critical routes Lighthouse 12+

Bundle budgets (apps/portal-shell/project.json, Angular budgets array, type: "error" — blocking at nx build):

Bundle Budget
Initial bundle (gzip) 300 KB
Any lazy chunk (gzip) 100 KB
Per-component CSS 6 KB
Total stylesheet 150 KB

These values are deliberately conservative for a zoneless + Signals Angular setup, which is compact by construction. Actual measurements may show we have headroom; the budget can be tightened (never loosened without ADR amendment) at quarterly review.

Critical routes for Lighthouse CI

Lighthouse CI runs against a curated list of routes that represent the user journey:

  • /auth/login — sign-in landing, first-impression critical;
  • / — post-auth home/dashboard;
  • /accessibility and /accessibilite — must themselves be perf-and-a11y exemplary (they are the portal's public proof);
  • a flagship route per integrated feature, added as features land (one route per feature lib).

Each route is scored against the thresholds above. Failure on any threshold is blocking — the merge is rejected.

Where Lighthouse CI runs

Environment Cadence Purpose Blocking?
CI on human-authored PRs per push Catch regressions before merge Yes
CI on Renovate (bot) PRs skipped Per-PR perf signal on a dep bump is ≈0; Lighthouse cost is ≥3-5 min per PR No (skipped)
CI on push to main (post-merge) per merge Catch regressions immediately after merge — applies to bot PRs too Yes
CI scheduled (weekly) on prod env cron in security-scheduled.yml from ADR-0015, extended to cover perf Detect drift / regressions in real environment Reports as alerts; doesn't block, but triggers triage
Local dev manual via pnpm nx run portal-shell:lighthouse Developer-side feedback Non-blocking

Pre-merge gating policy: human PRs vs bot PRs

The perf job is skipped on PRs whose author is the dedicated Renovate bot user (apf-portal-bot). Rationale:

  • A dependency bump on this codebase has near-zero direct impact on the bundle the user receives — the SPA today serves the static placeholder, and even once real routes ship, the Lighthouse score on a patch/minor dep bump rarely moves outside the median noise floor (±2-5 points).
  • A full Lighthouse round-trip (build prod + 3-iteration median across the critical-routes list) costs 3-5 minutes per PR. Multiplied by the steady-state Renovate cadence (~5-15 PRs per week once dependencies are diversified), this is a real wall-clock cost on shared runners with no reliable signal in return.
  • The post-merge push to main still runs the full perf gate. A regression that does sneak through a Renovate PR is caught seconds after merge, before the next PR queues a build — fast enough to revert without disruption.

This policy is conservative on human PRs (the perf gate stays mandatory pre-merge, where the latitude for change is highest) and pragmatic on bot PRs (where the change vocabulary is narrow and the post-merge gate already provides the safety net).

The same skip is applied to the commits job: Renovate generates commit messages from a Conventional-Commits-conformant template, so re-validating them with commitlint is tautological.

Edge case: if a human takes over a Renovate-authored PR and force-pushes substantive changes, the gate stays skipped (the PR author is still the bot). This is accepted as a marginal case; the human can either run ci:perf locally or accept that the post-merge push gate will catch any regression. Re-evaluation if it happens often enough to be a real annoyance.

Variability mitigation

Lighthouse scores fluctuate ±25 points run-to-run on the same code due to runner variance. The CI configuration:

  • runs 3 iterations per route and uses the median (configurable via lighthouserc.js);
  • uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
  • pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).

Bundle analysis — diagnosis tooling

source-map-explorer is wired as an Nx target (pnpm nx run portal-shell:analyze) to investigate budget breaches when they happen. Not part of CI — diagnostic tool only. The CI signal is the budget failure; analyze tells the developer where the weight comes from.

Back-end perf budgets

Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by ADR-0012. Initial budgets:

Endpoint family p95 p99
GET /auth/me 50 ms 150 ms
GET /auth/login (redirect) 80 ms 200 ms
GET /auth/callback (token exchange) 600 ms 1500 ms — bound by Entra
Read endpoints (DB-backed, simple) 80 ms 250 ms
Read endpoints (DB-backed, complex) 300 ms 800 ms
Write endpoints 200 ms 600 ms
Downstream-API-orchestrating endpoints bound by downstream + 50 ms BFF overhead bound by downstream + 200 ms BFF overhead

These budgets are not enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:

  • alerting thresholds in the production observability backend (chosen in the future infrastructure ADR);
  • review-time signals on PRs that touch hot paths (the OTel data from staging informs the reviewer);
  • quarterly perf review — tightened or loosened with ADR amendment.

Performance regressions are bugs, not backlog

A regression on any of the front-end gates blocks the merge for human PRs, and blocks push to main for any PR (including Renovate-authored ones — see the gating-policy subsection above). A regression on a back-end SLO that surfaces in production (alert fires) is triaged with the same priority as a security finding — root-caused, fixed, post-mortemed if it took the SLO out for more than 24 h.

a11y / perf trade-off

Performance optimisation must not hurt accessibility. Some specific patterns to avoid:

  • skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
  • removing focus-visible polyfills "for bundle size";
  • aggressive lazy-loading that delays critical interactive elements;
  • compression of imagery to the point of losing visual clarity required by low-vision users.

When the perf budget and the a11y bar conflict, a11y wins — the perf budget is then re-evaluated at the next quarterly review with the data point.

CI integration

package.json script:

"scripts": {
  "ci:perf":  "pnpm exec nx build portal-shell --configuration=production && pnpm exec lhci autorun --config=./lighthouserc.js"
}

lighthouserc.js (illustrative, lands with the scaffold) declares:

  • the URLs to test (the critical-routes list);
  • 3 iterations, median report;
  • assertions matching the thresholds in this ADR;
  • upload target = local filesystem in CI (HTML reports kept as build artefacts).

The perf gate from ADR-0015 is now wired and blocking.

RUM strategy

No browser-side RUM SDK in v1. The OTel browser tracing from ADR-0012 provides distributed-trace data for full user actions; combined with the Lighthouse CI scheduled prod runs and the BFF OTel spans, this gives sufficient signal to detect regressions. A v2 ADR will revisit if a real RUM tool is needed (concrete trigger: incidents that staging + Lighthouse + OTel didn't catch).

Consequences

  • Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
  • Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
  • Good, because Angular budgets blocks bundle bloat at build time, before it ever reaches a user.
  • Good, because the BFF SLOs reuse the OTel pipeline already shipped by ADR-0012 — no parallel telemetry.
  • Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
  • Bad, because Lighthouse score variability (±25 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
  • Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
  • Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.

Confirmation

  • lighthouserc.js exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
  • apps/portal-shell/project.json declares budgets with maximumWarning == maximumError (so an overshoot fails the build, matching type: "error" in spirit). Four entries: initial (≤ 1 MB raw), anyScript (≤ 300 KB raw), anyComponentStyle (≤ 6 KB raw, with 5 KB warning floor), bundle name=styles (≤ 150 KB raw). Angular CLI compares raw sizes; the values above are translated from the gzip-based ADR targets at the conventional ≈ 3× JS / ≈ 4× CSS compression ratios.
  • A complementary CI check in scripts/check-gzip-budgets.mjs (wired into pnpm ci:perf between the production build and Lighthouse) asserts the actual gzipped transfer size against the ADR thresholds: initial-JS-total ≤ 300 KB, any single lazy chunk ≤ 100 KB, total CSS ≤ 150 KB. Angular CLI does not natively support a gzip-mode budget — this script is the explicit gate. It parses index.html to distinguish initial assets from lazy chunks, gzips each file at level 9 (matching what HTTP servers typically serve), and exits non-zero on any breach. The check is per locale bundle@angular/localize (per ADR-0019) emits one self-contained bundle under dist/.../browser/<locale>/, and each is what the user's browser actually downloads, so each is checked against the same budget independently. The script auto-detects locale-split vs flat layouts.
  • package.json exposes ci:perf, runnable locally with the same exit code as CI.
  • CI's perf gate from ADR-0015 calls pnpm ci:perf. It is blocking on human PRs and on push to main; skipped on PRs authored by the Renovate bot user (apf-portal-bot) per the gating-policy subsection above.
  • apps/portal-shell exposes a Nx target analyze invoking source-map-explorer against the production build's source maps.
  • The BFF p95/p99 budgets are documented in apps/portal-bff/README.md and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
  • The security-scheduled.yml workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
  • Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.

Pros and Cons of the Options

Metrics scope

Google Core Web Vitals + supplementary + bundle size (chosen)

  • Good, because aligned with the industry, comparable to public benchmarks, well-documented.
  • Good, because INP captures real user interaction latency (replaces FID since March 2024).
  • Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.

Custom metrics only

  • Bad, because un-benchmarkable, easy to be wrong about. Premature.

Lighthouse score only

  • Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.

Front-end tooling

Lighthouse CI (chosen)

  • Good, because mature, free, the standard for automated CWV in CI.
  • Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
  • Bad, because score variability needs mitigation (median of 3, pinned version) — covered.

WebPageTest API

  • Good, because more granular network/profile control.
  • Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.

Calibre / Speedlify

  • Good, because modern, well-presented dashboards.
  • Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.

Threshold values

Google "Good" + Lighthouse ≥ 90 (chosen)

  • Good, because the de facto industry baseline; exceeding it is a clear positive signal.
  • Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.

Stricter (LCP ≤ 2 s, score ≥ 95)

  • Good, because reflects an a11y-first mission.
  • Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.

RUM

None in v1 (chosen)

  • Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
  • Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.

RUM SDK (Sentry, Datadog Browser)

  • Good, because real data, real users, real distribution.
  • Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.

More Information