Dev notes
A self-hosted vitals pipeline that collects real-user data from every page and aggregates it into P75 scores — built because owning the data matters more than reading a third-party dashboard.
Vercel Speed Insights is still in the app — it feeds into their dashboard and that's useful. But the data lives there, in their UI, not mine. Self-hosted means the data can be queried any way needed, displayed inside the app itself on the protected hub, and extended with version filtering or custom aggregations. Building the pipeline is also the more interesting part: knowing what sendBeacon does and why, how to store and aggregate percentile data in Postgres, how to wire a collection client to a backend — that's the stuff worth knowing as a developer.
The web-vitals npm package hooks into browser APIs to detect each metric at the right time and fires a callback with the name, value, and a rating. A WebVitalsReporter client component in the root layout registers all five collectors once on mount and sends each metric to /api/vitals. That Next.js route validates the shape and forwards it to the Express backend, which inserts one row per metric event into a web_vitals Postgres table.
Each beacon uses navigator.sendBeacon wrapped in a Blob with explicit application/json content type. Regular fetch gets killed when the browser tears down the page — INP and CLS fire on page hide, which is exactly when a regular fetch would be cancelled. The Blob wrapper forces the content type that Express's JSON parser expects; sendBeacon defaults to text/plain otherwise.
Averages hide the tail. If 90% of page loads take 1.2s and 10% take 8s, the average might look fine at 1.9s. P75 means 75% of users had a load time at or below that number — sensitive enough to catch real problems without being dominated by a single extreme outlier. Google uses P75 for the official Core Web Vitals thresholds in search ranking. The by-page table filters to pages with at least 5 samples — one data point isn't a distribution.
The Vitals landing section now has an interactive speedometer GLB. The needle animates from a resting “slow” position to the “good” zone when the section enters the viewport, using a frame-by-frame lerp in useFrame. The three primary metrics — LCP, INP, and CLS — are displayed as animated stat cards below the speedometer with spring-driven value counters and score bars. The raw GLB had bounding-box coordinates spanning tens of thousands of units, so a Box3 auto-fit runs in useEffect after load to scale and center the model dynamically — a general-purpose pattern for any GLB with unknown native units.
The original version selector was a flat dropdown of every patch version — fine with 10 versions, unusable with 50. The new selector groups versions into tiers: “Current Major” (all data in the major version, the default), “Current Minor” (all patches in the latest minor), the last 3 minor versions with each patch shown individually in optgroups, and everything older collapsed into one entry per minor version with aggregated data.
The URL encodes the filter mode via prefix: major:0 for all 0.x.y versions, minor:0.12 for all 0.12.x patches, or a bare 0.11.3 for an exact match. The backend's buildVersionConditions helper translates the mode into the right SQL — major uses split_part on the first segment, minor matches both first and second, and exact does a straight equality check. The trend chart adapts too: minor mode returns up to 30 patch versions so you see the full progression within a minor, while major mode caps at 10.
INP improved by replacing transition-all with explicit property lists across the codebase and wrapping the hub's mount animation update in startTransition. LCP on the hub improved by removing the reveal() wrapper from the H1 heading — browsers exclude opacity: 0 elements from LCP consideration entirely. TTFB on the hub improved by making page.tsx a plain sync component with no auth calls, so Next.js can statically pre-render it — TTFB drops from ~2.1s to ~50ms. CLS on the vitals page improved by extracting a CHART_CONTAINER_HEIGHT constant shared by both the skeleton div and the real chart wrapper so they reserve the same space.