--- status: accepted date: 2026-04-30 decision-makers: R&D Lead tags: [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](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) 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](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution. - a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately. - Industry-standard tooling, anti-bricolage. - Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path. - Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — 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](0012-observability-pino-opentelemetry.md)), 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](0015-cicd-gitea-actions.md), 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 ±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](0012-observability-pino-opentelemetry.md). 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: ```jsonc "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](0015-cicd-gitea-actions.md) is now wired and blocking. ### RUM strategy No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-observability-pino-opentelemetry.md) 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](0012-observability-pino-opentelemetry.md) — 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.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`](../../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](0019-internationalisation-angular-localize.md)) emits one self-contained bundle under `dist/.../browser//`, 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](0015-cicd-gitea-actions.md) 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 - 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](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).