From 41fa2032ae280ba89346f111ca57d1a563b5ef89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 16:23:54 +0200 Subject: [PATCH] docs(audit): v1.5 performance audit report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures Playwright+PerformanceObserver measurements against production (https://healthlog.bombeck.io, /api/version=1.4.13) at 2026-05-09T14:14Z. Four pages × two viewports (1920x1080 desktop, Pixel 5 mobile): /, /settings/integrations, /admin, /insights. Key findings: - /insights eagerly imported Recharts (108.5 KiB) — fixed in bb2b1de via per-symbol next/dynamic. - Dashboard checklist fired 2 redundant API calls per load for established users (~950 ms wasted) — fixed in 519e36e by gating on user.onboardingCompletedAt. - Recharts replacement deferred to v1.5.1 (no new deps in v1.5). Local re-measurement is blocked by the Node-25 turbopack regression already noted in CLAUDE.md; verification will land via the v1.5.0 prod measurement once Coolify force-pulls the new bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/audit/v15-performance.md | 233 ++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/audit/v15-performance.md diff --git a/docs/audit/v15-performance.md b/docs/audit/v15-performance.md new file mode 100644 index 000000000..ca3f35926 --- /dev/null +++ b/docs/audit/v15-performance.md @@ -0,0 +1,233 @@ +# v1.5 Performance Audit — Production + +**Status**: phase-4 of the v1.5 marathon +**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= 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 Marc's 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.5 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 + (Marc has `onboardingCompletedAt = 2026-02-20` 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 + `` 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.5.1**) + +- **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.5.0 — track in v1.5.1 backlog. +- **Why deferred**: hard rule "no new dependencies in v1.5". + +### 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 `` in `sidebar-nav.tsx` and one + `` in `admin/feedback-inbox-section.tsx` exist. The avatar is + in-viewport; the feedback `` is admin (Phase 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 `CLAUDE.md`'s pnpm-build entry). Wins #1 and #2 +therefore can't be re-measured against prod until v1.5.0 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.5.0 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.