Files
HealthLog/docs/audit/v1414-performance.md
T
Marc-André BombeckandClaude Opus 4.7 a1cf9bcb37 docs: scrub PII + internal jargon + German leaks from user-facing artifacts
Retroactive cleanup of CHANGELOG.md and docs/audit/v1.4.X-summary.md
files. Removes maintainer's real name, specific health figures tied to
the live tenant, internal release-process jargon, and stray German
strings in English-only release notes. Pre-release PII-grep gate added
to the v1.4.20 release pipeline. No git history rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:14:28 +02:00

234 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# v1.4.14 Performance Audit — Production
**Status**: stage-4 of the v1.4.14 release cycle
**Measured against**: `https://healthlog.bombeck.io` (`/api/version` → `1.4.13`)
**Measured at**: `2026-05-09T14:14:48Z` → `2026-05-09T14:15:27Z`
**Tool**: standalone Playwright + Chromium 1217 driver, `PerformanceObserver`
(`largest-contentful-paint`, `longtask`) + Resource Timing API + Playwright
`response`-event capture for transferred bytes (Cloudflare strips
`Content-Length` on chunked responses, so the Resource Timing
`transferSize` would otherwise read 0). Script lives at
`/tmp/v15-perf-measure.mjs` (one-shot, not committed — re-run with
`SESSION=<cookie> node ./.v15-perf-measure.mjs > /tmp/v15-perf-results.json`
inside the repo so the script picks up `node_modules/@playwright/test`).
Two viewports per page: **desktop** (`1920×1080`, default Chromium UA) and
**mobile** (Playwright's `Pixel 5` profile).
## Method
For each `(page × viewport)` cell:
1. Open a fresh BrowserContext with an authenticated session cookie
(`healthlog_session=cmox4d6fj…`) injected pre-navigation.
2. Inject a `PerformanceObserver` via `addInitScript` BEFORE navigation so LCP
and longtask events from the very first paint are captured.
3. `page.goto(url, { waitUntil: "load", timeout: 45_000 })` and then idle for
3 s so deferred chart code, lazy queries and below-the-fold content settle.
4. Read `performance.getEntriesByType("navigation")[0]`,
`performance.getEntriesByType("resource")`, plus the longtask / LCP
captures we accumulated in `window.__perfState`.
5. Total Blocking Time is the longtask-derived proxy:
`Σ (longtask.duration − 50ms)` over all longtasks ≥ 50 ms.
The "TTI" column in the original brief is omitted because Chromium's
canonical TTI requires a 5 s quiet-window heuristic that isn't worth
synthesising here — `Load` + `TBT` are the actionable signals.
## Per-page metrics
| page | viewport | TTFB ms | DCL ms | Load ms | LCP ms | TBT ms | Longtasks | Total KiB | JS KiB | CSS KiB |
| ------------------------ | -------- | ------: | -----: | ------: | -------: | ------: | --------: | --------: | -----: | ------: |
| `/` | desktop | 396 | 1080 | 2497 | **2828** | **624** | 1 | 604 | 425 | 16 |
| `/` | mobile | 384 | 752 | 1560 | 2184 | 0 | 0 | 604 | 425 | 16 |
| `/settings/integrations` | desktop | 500 | 738 | 1498 | 2396 | 0 | 0 | 394 | 297 | 16 |
| `/settings/integrations` | mobile | 495 | 771 | 1545 | 1900 | 0 | 0 | 386 | 297 | 16 |
| `/admin` | desktop | 584 | 761 | 1611 | 1932 | 0 | 0 | 382 | 291 | 16 |
| `/admin` | mobile | 872 | 1112 | 1920 | 2268 | 0 | 0 | 378 | 291 | 16 |
| `/insights` | desktop | 519 | 681 | 1609 | 2264 | 27 | 1 | 608 | 419 | 16 |
| `/insights` | mobile | 502 | 667 | 1647 | 2380 | 28 | 1 | 604 | 419 | 16 |
**Read in plain English**: every page comes in well under 2.5 s LCP except
`/` desktop (2.83 s) and `/insights` (~2.3 s). Mobile is faster than desktop
in absolute LCP terms because the Pixel 5 profile uses a smaller viewport
(less LCP candidate area) — the work is the same.
`/` desktop racks up 624 ms TBT from a single longtask: that's the dashboard
hydration cost for the `useQuery` × 9 + `TrendCard` × 8 + `HealthChart`
dynamic-imports firing in parallel.
## Top-5 JS bundles per page
> All sizes are compressed Brotli from CF. The `0vl43uc.17vx1.js` chunk
> appears on every page — that's the framework + Radix + TanStack Query
> bedrock. The `0m4t~kff9equt.js` chunk (52.2 KiB everywhere) is the next.js
> app-router shell + `radix-ui/dialog/dropdown`. The notable per-page chunks
> are flagged with **bold** below.
### `/` (desktop + mobile, identical)
- `0503y5tur7j1f.js` — **108.6 KiB** ← Recharts (loaded via `next/dynamic` from `health-chart` / `mood-chart`, but still on the critical path because the dashboard mounts ≥ 1 chart for any user with measurements)
- `0vl43uc.17vx1.js` — 71.3 KiB ← framework + TanStack Query (every page)
- `0m4t~kff9equt.js` — 52.2 KiB ← Next router shell + Radix overlay primitives
- `0~1ef0ki4ccp~.js` — 36.7 KiB ← shared app code (i18n + auth-shell)
- `0uc70mahb3xof.js` — 15.3 KiB ← `dashboard/page.tsx` per-page chunk
### `/settings/integrations` (desktop + mobile, identical)
- `0vl43uc.17vx1.js` — 71.3 KiB
- `0m4t~kff9equt.js` — 52.2 KiB
- `0~1ef0ki4ccp~.js` — 36.7 KiB
- `01q.1mkz27ebz.js` — 19.8 KiB ← settings/integrations page
- `0d3x9g6k_smzl.js` — 15.0 KiB
### `/admin` (desktop + mobile, identical)
- `0vl43uc.17vx1.js` — 71.3 KiB
- `0m4t~kff9equt.js` — 52.2 KiB
- `0~1ef0ki4ccp~.js` — 36.7 KiB
- `0d3x9g6k_smzl.js` — 15.0 KiB
- `0of675ww0w6~y.js` — 13.6 KiB ← admin status-card-grid
### `/insights` (desktop + mobile, identical)
- `18b.35leqj.iy.js` — **108.5 KiB** ← Recharts, eagerly imported because `insights/page.tsx` uses `ScatterChart`/`Scatter`/`XAxis`/`YAxis`/`CartesianGrid`/`Tooltip`/`ResponsiveContainer` symbols at module top-level (line 42–50)
- `0vl43uc.17vx1.js` — 71.3 KiB
- `0m4t~kff9equt.js` — 52.2 KiB
- `0~1ef0ki4ccp~.js` — 36.7 KiB
- `16c~twjymta_z.js` — 15.6 KiB
## Treemap-flavoured grouping
| group | KiB (uncompressed est ×3 or measured) | notes |
| -------------------------------------------- | ------------------------------------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Recharts (per page that uses it) | ~108 KiB | by far the heaviest single dep; v1.4.14 candidate for replacement. v1.4.6 already moved `HealthChart`/`MoodChart` to `next/dynamic`, but `/insights` still imports the Scatter primitives eagerly. |
| Framework (Next.js + React + TanStack Query) | ~71 KiB | unavoidable baseline |
| Radix primitives + app-router shell | ~52 KiB | unavoidable baseline |
| Shared app code (i18n, auth-shell, layout) | ~37 KiB | unavoidable baseline |
| Per-page app code | 13–20 KiB | within budget |
Pages without charts (`/admin`, `/settings/integrations`) are about 132 KiB
lighter than the chart pages, confirming Recharts is the dominant
single-dependency cost.
## Slowest API calls observed
Measured for `/` desktop (worst case). Numbers are the per-request
`PerformanceResourceTiming.duration` (queue + DNS + connect + TTFB + body):
| endpoint | ms |
| -------------------------------------------------- | --: |
| `/api/measurements?type=WEIGHT&limit=500&offset=0` | 812 |
| `/api/notifications/preferences` | 654 |
| `/api/gamification/achievements` | 651 |
| `/api/measurements?type=PULSE` | 506 |
| `/api/measurements?type=BLOOD_PRESSURE_SYS` | 404 |
| `/api/measurements?type=BLOOD_PRESSURE_DIA` | 399 |
| `/api/mood/analytics` | 351 |
| `/api/analytics` | 342 |
| `/api/medications` | 340 |
| `/api/withings/status` | 303 |
Two surprises:
- `notifications/preferences` and `withings/status` are fired by the
`GettingStartedChecklist` even when the user is well past onboarding
(the live tenant has `onboardingCompletedAt` set since early 2026 and
1000+ measurements). The checklist short-circuits with `null` after
fetching, so the network is wasted.
- `gamification/achievements` is hot-polled every 2 minutes by
`AchievementUnlockNotifier`. Acceptable cadence; not on the win list.
## Top 3 wins
### 1. Defer Recharts imports on `/insights` (S, **implemented**)
- **File**: `src/app/insights/page.tsx`
- **What**: Replace the eager `import { ScatterChart, … } from "recharts"`
with per-symbol `next/dynamic` imports (`ssr: false`). Each scatter card
is below the fold — it sits inside the correlation cards that gate on
`data.bpMedicationScatterData.length >= 5` etc.
- **Expected impact**: Moves the 108.5 KiB Recharts chunk off the initial
bundle. LCP-critical content no longer waits for Recharts to parse on
the main thread. Same Recharts that the dashboard already pays for is
shared at runtime, so no double-fetch on cache-warm navigation.
- **Effort**: ~10 LOC (the seven recharts symbols become seven
`dynamic(...)` consts).
- **Risk**: each first paint of a scatter section will Suspense-fallback
for ~1 frame while the chunk downloads. That matches the existing
`<HealthChart>` UX on the same page.
### 2. Skip `GettingStartedChecklist` data fetches for post-onboarding users (S, **implemented**)
- **File**: `src/components/onboarding/getting-started-checklist.tsx`
- **What**: Gate `withings/status` and `notifications/preferences` on
`!!user && user.onboardingCompletedAt == null`. The two queries fire
unconditionally today, then the checklist self-hides because the user
has already completed onboarding.
- **Expected impact**: 2 fewer API calls per dashboard load for every
established user. Saves ~950 ms of network on `/` desktop.
- **Effort**: 2 LOC (one extra clause in each `enabled:` field).
- **Risk**: the corner case of a returning user with
`onboardingCompletedAt != null` but `< 5` measurements (e.g. they
deleted readings post-onboarding) will see a checklist with stale
`withingsConnected` / `notificationsConfigured` flags. Acceptable —
the checklist already gracefully renders unknown defaults.
### 3. Replace Recharts on the dashboard (L, **deferred to v1.4.15**)
- **What**: Drop Recharts in favour of a smaller library (Visx, Chart.js
4, or hand-rolled SVG). Recharts is ~108 KiB Brotli for a use case that
needs ~3 chart variants (line, area, scatter).
- **Expected impact**: −80 to −100 KiB initial JS on `/` and `/insights`;
TBT win on `/` desktop because Recharts hydration is the dominant
longtask source.
- **Effort**: L. New dep + every chart in `src/components/charts/` needs
rewrite. Out of scope for v1.4.14 — track in v1.4.15 backlog.
- **Why deferred**: hard rule "no new dependencies in v1.4.14".
### Bonus / honourable mentions
- `Achievements` polling cadence on the dashboard is 2 min, staleTime 60 s.
Reasonable, no win.
- `staleTime: 5 * 60 * 1000` is already the global default in
`providers.tsx` — no global tuning needed.
- Image lazy-load: only the `<AvatarImage>` in `sidebar-nav.tsx` and one
`<img>` in `admin/feedback-inbox-section.tsx` exist. The avatar is
in-viewport; the feedback `<img>` is admin (Stage 4b territory). Skip.
## Verification
Local `pnpm build` cannot complete on this machine due to a Node-25
upstream regression (`Cannot read private member #state from an object
whose class did not declare it` in turbopack's prerender path; this is
the same bug noted in the repo doc's pnpm-build entry). Wins #1 and #2
therefore can't be re-measured against prod until v1.4.14 ships and
Coolify force-pulls the new bundle. Both wins are nevertheless verified
by static code review:
- **Win #1 — defer Recharts**: every changed import follows the exact
same `next/dynamic(... ssr: false)` shape that
`src/app/insights/page.tsx` already uses for `HealthChart` and
`MoodChart`. Next 16 / Turbopack splits each `dynamic(...)` callsite
into its own async chunk. Recharts internally re-uses one shared
module so the seven dynamic imports collapse to a single chunk request
at runtime, identical to the existing dashboard behaviour.
- **Win #2 — skip checklist fetches**: `onboardingPending` is read once
per render from `user.onboardingCompletedAt`. When false, both
`useQuery({ enabled: onboardingPending })` calls stay idle. The
downstream `shouldShowChecklist()` already hides the component for
every post-onboarding user with ≥ 5 measurements, so no UI regresses.
`pnpm test` (95 files / 733 tests) is green after the change.
A re-measure after v1.4.14 ships should show:
- `/insights` initial JS dropping from ~419 KiB to ~310 KiB (the 108 KiB
Recharts chunk moves out of the critical path and is only fetched if a
scatter section actually mounts).
- `/` dashboard total request count dropping from 9 to 7 for any user
with `onboardingCompletedAt != null`, eliminating the
`notifications/preferences` (~654 ms) and `withings/status` (~303 ms)
calls.
If post-deploy measurement contradicts either expectation, revert the
relevant commit and update this document.