## Summary Closes the ADR-0017 follow-up that was sitting on an unpushed local branch (`feat/ci/gzip-transfer-size-budgets`) for ~5 days, while ADRs 0018–0021 and the auth/admin/security tracks landed. Cherry-picked onto current `main`, with a small follow-up fix to make the script work against the locale-split build layout introduced by ADR-0019 between the two commits. ## What lands ### Commit 1 — `feat(ci): assert gzip transfer sizes against ADR-0017 budgets` [`scripts/check-gzip-budgets.mjs`](scripts/check-gzip-budgets.mjs) — plain-Node, no deps, ~120 LOC at landing. Closes the ADR-0017 §Confirmation gap (Angular CLI's `budgets` only compare RAW sizes): - Parses Angular's emitted `index.html` to separate **initial** assets (anything referenced via `src=` / `href=`) from **lazy** chunks (the rest). - Gzips every JS / CSS file at level 9 — what most HTTP servers serve for static assets — and reports a per-file + per-bucket table. - Asserts the [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md) thresholds (`initial JS total ≤ 300 KB`, `any lazy chunk ≤ 100 KB`, `total CSS ≤ 150 KB`) and exits non-zero on any breach. - Wired through: - `pnpm ci:gzip-budgets` invokes the script with the default dist path. - `ci:perf` chains build → gzip check → Lighthouse, so a budget breach short-circuits before Lighthouse even runs. ADR-0017 §Confirmation also updated to point at the script instead of describing it as a "future follow-up". ### Commit 2 — `fix(ci): adapt gzip-budget check for the @angular/localize multi-bundle layout` The original commit predated PR #91 / [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md), which made `@angular/localize` emit 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 build since. Fix: - Auto-detects layout. If `dist/index.html` exists → flat mode (unchanged behaviour, kept for single-locale apps like `portal-admin`). Otherwise enumerates immediate subdirectories with their own `index.html` and runs 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 + reported together with the locale tag prefixed so a single CI run surfaces every breach. - ADR-0017 §Confirmation amendment expanded to spell out the per-locale check + cite ADR-0019. ## Test plan - [x] `node --check scripts/check-gzip-budgets.mjs` — syntax OK. - [x] `pnpm ci:gzip-budgets` against the current 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 ✓. - [x] No conflict on `package.json` despite 11 PRs touching it since the branch base — cherry-pick auto-merged cleanly. - [ ] CI: `pnpm ci:perf` end-to-end (build → gzip-budgets → Lighthouse). Validates on the runner. ## Notes for the reviewer - The script lives at the repo root under `scripts/` rather than as an Nx target — it's a pure CI helper, not project-scoped. Matches the existing `ci:audit` / `ci:commits` pattern in `package.json`. - No dependencies added. Uses `node:fs/promises` and `node:zlib`. - The original branch (`feat/ci/gzip-transfer-size-budgets`) is preserved in local-only state for paranoia; once this PR merges, it can be `git branch -D`'d. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #133
21 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||||
|---|---|---|---|---|---|---|---|
| accepted | 2026-04-30 | R&D Lead |
|
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
perfgate 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 Angularbudgetsfor 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
budgetsinproject.json, typeerror, 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;/accessibilityand/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
pushtomainstill runs the fullperfgate. 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 ±2–5 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
budgetsblocks 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 (±2–5 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.jsexists 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.jsondeclaresbudgetswithmaximumWarning == maximumError(so an overshoot fails the build, matchingtype: "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 intopnpm ci:perfbetween 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 parsesindex.htmlto 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 underdist/.../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.jsonexposesci:perf, runnable locally with the same exit code as CI.- CI's
perfgate from ADR-0015 callspnpm ci:perf. It is blocking on human PRs and onpushtomain; skipped on PRs authored by the Renovate bot user (apf-portal-bot) per the gating-policy subsection above. apps/portal-shellexposes a Nx targetanalyzeinvokingsource-map-exploreragainst the production build's source maps.- The BFF p95/p99 budgets are documented in
apps/portal-bff/README.mdand translate into alert rules in the production observability backend (configured per future infrastructure ADR). - The
security-scheduled.ymlworkflow 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
- Web Vitals overview: https://web.dev/vitals/
- Core Web Vitals (Google): https://web.dev/articles/vitals
- Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
- Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
source-map-explorer: https://github.com/danvk/source-map-explorer- Related ADRs: ADR-0004 (Angular stack — CSR perf profile), ADR-0012 (OTel — basis for BFF SLOs), ADR-0015 (CI gates —
perfslot now wired), ADR-0016 (a11y / perf trade-off rule).