This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
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.
For a long time this dashboard sat behind a login, and this write-up above describes it that way — the page redirected signed-out visitors, and the BFF routes forwarded a JWT that the backend’s checkJwt verified like any protected route. That never sat right with me, because the data here isn’t personal: it’s the site-wide P75 for every visitor’s page loads, aggregated. There’s nothing in it that belongs to any one account, so gating it only made it harder to look at and easy to describe inconsistently — the menu link showed only when you were logged in, half the copy called it “protected,” and the other half called it “site-wide.”
So Web Vitals is public now, and it is not auth-gated anywhere. The proxy no longer redirects /vitals — that gate lives in a small isSessionProtectedPath helper that lists only /settings and /calendar. The page and the BFF routes still forward a token when the visitor has one, but they no longer require it — a signed-out request just goes through unauthenticated, and the menu shows the link to everyone. The one thing that has to follow is the backend: /api/vitals/summary and its siblings still verify the JWT, so until that side opens up too, a signed-out visitor sees the dashboard shell with the numbers empty rather than the full picture. Graceful, but the real fix is both halves.
Update — August 24, 2026
The nightly alert kept opening an issue: a dozen pages in the Poor band, LCP of ten, fifteen, twenty-one seconds. I nearly went and “optimised” them — and that would have been the mistake. Nine of the twelve were static pages whose largest element is a line of server-rendered heading text. There is no world where that paints in twenty-one seconds for a real visitor on a CDN. The numbers were wrong, not the pages.
The aggregation had no memory and no floor. The P75 was computed over the entire web_vitals table, all of history, with no bound on the value. So one impossible sample — a tab opened in the background, which renders whenever the browser gets around to it and reports a load timing of minutes — became a permanent member of the percentile and never aged out. A PERCENTILE_CONT pulled toward that tail stays elevated forever. The fix was two honest constraints: the current-health views read a rolling 28-day window instead of all time, and every value is bounded to a physically-plausible range (a timing past a minute, a layout-shift score past ten, is not a real user) at both ingest and read.
And I stopped recording the garbage at the source. A page that loaded hidden produces load timings nobody waited through, so the reporter now checks document.visibilityState at load and simply doesn’t send LCP/FCP/TTFB from a background load; CLS and INP, which are scoped to the interaction, still go. Then the three pages that were actually guilty: the GraphQL Pokédex skeleton reserved the search bar and grid but not the filter row between them, so the grid jumped when it streamed in — the skeleton renders the real filter row now; the Pocket loading cards were a uniform height that didn’t match the real ones; and an operator store detail was painting its header only after a client round-trip, so I seeded it with the store the page already loaded on the server. The lesson I keep relearning: before optimising the thing the metric points at, check that the metric is pointing at a real thing.
One more, and it came from the CI for this very change. The Pocket page is force-dynamic and reads its sets live from tcgdex; when that upstream didn’t answer from the runner, the SDK threw, the page rendered nothing, and the accessibility scan timed out waiting for a main landmark that never arrived. A null-check was there but the failure was a throw, not a null, so it sailed past. It catches now and degrades to a readable, accessible “unavailable” state with its real page shell — a third party being down should cost a page its data, not its structure.
Update — August 10, 2026
The page and its BFF routes used to require a login. That was the wrong call and I reversed it: these are real-user Core Web Vitals for a public site, collected from public page loads. There is nothing private in a P75 LCP, and putting it behind auth mostly meant nobody ever looked at it — including me. It is public now, and the wording says so everywhere it is mentioned, because a page that says "sign in" anywhere teaches people not to try.
The first paint disagreed with its own selector. The version selector defaulted to the current major while the initial fetch asked for something else, so the numbers on screen were not the numbers the control claimed to be showing. Nobody would notice unless they were checking a specific release — exactly when it matters most. Two sources of truth for "which version am I looking at" was the real defect; making them one fixed it.
Update — August 15, 2026
I lost an afternoon to this one, chasing a data bug in an API that was simply down. The versions call swallowed every failure and returned an empty list, the default scope resolved to version "0" — which has never existed — and the summary, by-page and by-version calls all went out behind it. Nothing answered those either, so the page drew five metric cards reading "No data yet". An outage rendered as good news.
The versions call is the health probe now, since it already ran first and alone. A transport failure or a 5xx says so on the page and skips the other three requests, so there is no invented scope left to leak. Everything the backend actually said is unchanged: a 404 still means the endpoint is not deployed in that environment and the selector just hides, and a genuinely empty dataset still gets "No data yet", because that one is true.
The premise I started with was wrong, and finding that out was the useful part. A refused connection was never the silent case — the fetch throws and the error boundary catches it. The case that lied was a backend answering with errors, where every fetch fell back to empty on a non-ok response. I only learned that by pointing the app at a dead port and getting the error boundary instead of the fake-empty dashboard I expected.
A second, quieter version of the same bug survived that fix: a healthy backend with an empty history is a fresh database, and the scope still resolved to major "0". The local logs read like an error while every response was a correct 200. An empty history now means all-time queries with no version filter at all.
The API had its own half of it. Chasing the 500s led into the service, where the major and minor filters cast split_part(app_version, '.', 1) to an integer guarded only by a check for the literal string "unknown", and the version sorts cast the whole string to an int array with no guard at all. One row with a non-numeric version — a "dev" build is enough — turns those endpoints into 500s for every caller. Both paths share a regex gate now, and a junk version filter matches nothing and returns an empty 200, which is what that module already did for a junk version without a mode.
Update — August 18, 2026
The list of things this could do better opened, for months, with a line I kept leaving in: it reports and does not alert, so a regression is visible only if someone opens the page. That is the wrong way round for a number whose whole job is to catch things getting worse. This closes it.
A Vercel Cron runs once a day and hits a new endpoint, GET /api/vitals/alert. It reads the same site-wide P75 summary the dashboard reads, and checks each metric against Google's Poor threshold — the same thresholds the cards already colour by, so there was nothing new to define. The line for "bad" was already on the page; it just needed something watching it.
The channel is a single GitHub issue. When a metric is in the Poor band the cron opens one, labelled vitals-alert. While it stays bad the same issue is updated rather than a fresh one filed every noon, and when every metric comes back under threshold the issue is closed with a comment. So an open vitals-alert issue means a vital is bad right now, not that one was bad once in April.
The decision of what counts as bad is a pure function with no network in it — the only part worth testing hard — and the route and the GitHub client are the thin shell around it. It also fails quiet on purpose: a missing token or a GitHub hiccup reports the breach but skips filing rather than failing the cron, and a backend that will not answer dispatches nothing, so a flaky API can never open a phantom alert.
Deliberately narrow for a first cut: absolute Poor-band breaches on the site-wide number only. Per-route budgets, and catching a regression against the last release rather than an absolute floor, are the obvious next steps — each wants its own change, because each needs a second source of data.
Update — August 18, 2026
The first cut watched one number, the site-wide P75. Two paragraphs up I listed what that misses; this closes both, on the same cron and the same issue.
Per page. The same Poor-threshold check runs over every page's P75 now, not just the roll-up. A page at LCP 4.3s while the average sits at a healthy 2.1s used to stay silent, because the mean smoothed it out. It opens the issue now — the check is the same function, handed one page's metrics instead of the site's.
Per release. The by-version data already drives the trend chart, oldest to newest. The alert reads the last two and flags any metric that dropped a rating band — Good to Needs-improvement, or worse — between them. Band-crossing rather than a percentage, on purpose: a twenty-percent swing that stays inside one band is mostly noise, while a band drop is the thing a visitor would actually feel. A thirty-sample floor on the newest version keeps a release that shipped an hour ago from firing on three data points.
All three share the one issue. The body grew three sections, each omitted when it has nothing, and the route reads summary, by-page and by-version together. The summary is still the only one that can 502 the run; the other two degrade to an empty section rather than taking the alert down, because a missing trend is no reason to stop reporting a Poor page.