Skip to main content

Web Vitals

Core Web Vitals are field measurements a browser can only take while a real visitor uses the page. Opt in and the package beacons them to a server-side route, which records each as a Matomo event through the normal tracking path.

'web_vitals' => [
'enabled' => false,
'path' => 'matomo-analytics/web-vitals',
'category' => 'Web Vitals',
'metrics' => ['LCP', 'CLS', 'INP', 'FCP', 'TTFB'],
'throttle' => '60,1', // route throttle "requests,minutes"; null to disable
'middleware' => [], // extra middleware for the ingest route; see below
'library' => null, // optional script URL; null = your app provides it
],

Then drop the directive into your layout:

@matomoWebVitals

Distinct from page performance, which is Matomo's own page-timing report and fills itself. Core Web Vitals are Google's metrics and arrive here as Matomo events, so they show up in the Behavior reports rather than in Performance.

You provide the library

The snippet expects Google's web-vitals library on window.webVitals. Bundle it with your own assets, or point web_vitals.library at a self-hosted copy and the package renders the script tag for you.

No third-party CDN is ever loaded by default. That is a privacy decision, not an oversight: an analytics integration that silently pulls a script from someone else's origin undoes much of the reason to run Matomo. If the library is not present, the snippet is a clean no-op — nothing errors and nothing is sent.

The directive accepts a Content-Security-Policy nonce, like @matomoScript:

@matomoWebVitals($cspNonce)

One measurement per page load

Both tags the directive emits are marked data-navigate-once, so a client-side navigation does not re-run them. That is what the metrics are defined against: a Core Web Vital describes a page load, and the library reports the navigation type itself, so a soft navigation is already visible in the data without being measured as a second load.

Re-running the snippet would not produce a second, cleaner measurement — it would register another set of observers beside the first, so every metric would be reported once per navigation the visitor had made. Nothing fails in that state and the numbers stay plausible; they simply scale with how deeply a session browsed.

What travels, and how

For each metric the browser reports, the snippet sends one navigator.sendBeacon call carrying a small JSON body: the metric name, its value, the rating the library assigned, the navigation type, and the page's own URL. sendBeacon is what makes this reliable — it survives the page being closed, which is exactly when the final LCP and CLS values become known.

The URL is what attributes the measurement to a page. Without it the event would be filed under the ingest endpoint, and you would learn the metric and the rating and never which page was slow. It is accepted only for this application's own origin: it is unauthenticated input on a public endpoint, and a form-encoded cross-origin POST needs no preflight, so any page anywhere can make its visitors beacon this route. A URL from elsewhere is dropped rather than refused — the measurement is still real, it simply loses its page.

The server side records each sample as a Matomo event: the configured category, the metric name as the action, the rating as the event name, and the measurement as the numeric value. So a report grouped by event action gives you the distribution per metric, and grouping by name splits it into good, needs improvement and poor.

Recording goes through the same tracking gate as every other hit — a bot, an opted-out visitor or an excluded route produces no event. The excluded-route half works because of that URL: except_routes is matched against the page the measurement was taken on. Before, the gate saw only the beacon's own path, which no exclusion list ever names.

⚠️ tracking.track_authenticated and tracking.except_abilities still see a guest here. The route is in no middleware group and starts no session, because the browser beacons it with sendBeacon and that carries no CSRF token. Set web_vitals.middleware to ['web'] if you need those two rules to apply, and exempt the one route from CSRF on your side.

The rate limit is keyed on the visitor, not on the proxy. web_vitals.throttle is enforced through a named limiter keyed on the address this package resolves — the same ip_header that feeds cip and except_ips. Laravel's own throttle keys on the request IP, which behind a CDN is the proxy for every visitor alike, so a single bucket would have held all of them.

The route

The ingest route is always registered, whether or not the feature is enabled. Toggling web_vitals.enabled is therefore a pure configuration change: it never requires a route-cache rebuild. When the feature is off, the controller answers 404.

Two defenses bound what the route accepts:

  • throttle applies Laravel's rate limiter to the route — 60,1 means sixty requests per minute per client. Set it to null to remove the limiter, but only if something else in front is already bounding it.
  • The metric allowlist. A beacon whose metric is not in web_vitals.metrics is rejected without being tracked, as is a non-numeric value. The route is public by necessity — the browser has to reach it — so it accepts only the shape it documents.

Change path if the default collides with a route of your own. Change metrics to narrow what you store; removing a metric from the list stops it being accepted, not merely stops it being sent.