docs: add ADR-0017 for performance budgets (Core Web Vitals + Lighthouse CI + Angular budgets + BFF SLOs)
Pin the perf framework. Front-end metrics tracked at Google Core Web Vitals 'Good' thresholds (LCP <= 2.5s, INP <= 200ms, CLS <= 0.1, plus TBT <= 200ms, TTFB <= 800ms) and Lighthouse Performance >= 90 on critical routes. Lighthouse CI (@lhci/cli) enforces them in CI with median-of-3 runs to mitigate runner variance, on a curated critical-routes list (login, home, accessibility statement, flagship features as they land). Wires into the perf gate slot from ADR-0015. Bundle budgets enforced at nx build via Angular's project.json budgets array, type 'error': initial <= 300 KB gzip, lazy chunks <= 100 KB gzip, per-component CSS <= 6 KB. source-map-explorer wired as an Nx analyze target for diagnosis on budget breaches. Back-end SLOs documented per endpoint family (p95/p99) and observed via the OpenTelemetry spans already shipped by ADR-0012 - advisory in CI (load profile unrepresentative), alerting in production. Quarterly review tightens budgets when achievable. Scheduled weekly Lighthouse run on the prod env via the existing security-scheduled.yml workflow extends coverage beyond PR-time. Explicit a11y/perf trade-off rule: when they conflict, a11y wins (per APF's mission, ADR-0016). The perf budget is then re-evaluated at the next quarterly review. No browser-side RUM SDK in v1 - the OTel browser tracing from ADR-0012 plus scheduled prod Lighthouse runs cover the gap. RUM revisited in v2 if a real incident escapes the existing signal. decisions/README.md index updated. CLAUDE.md gains an explicit 'Performance budgets' line pointing to ADR-0017.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
---
|
||||
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 every PR** | per push | Catch regressions before merge | **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 |
|
||||
|
||||
### 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. 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` of type `"error"` with the values above.
|
||||
* `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` and is blocking.
|
||||
* `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).
|
||||
@@ -60,3 +60,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
|
||||
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
|
||||
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
|
||||
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
|
||||
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
|
||||
|
||||
Reference in New Issue
Block a user