Skip to main content

Prefetched pages

A browser that supports Speculation Rules fetches a page before anybody has asked for it. With eagerness: moderate that happens when the pointer merely rests on a link.

For analytics that is two problems, not one, and they pull in opposite directions:

What the server seesWhat it means
the prefetcha GET with a full 200 HTML bodya page nobody has opened
the click afterwardsnothing at allthe browser serves the page out of the prefetch

Count the prefetch and every hovered link becomes a page view. Skip it and the visit that follows disappears. The package does both halves.

The prefetch is skipped

middleware.skip_prefetch is true by default. A request whose Sec-Purpose or Purpose header carries prefetch is not tracked, the same way a wire:navigate prefetch is not.

Both headers are read because they are not interchangeable in the field: Sec-Purpose is the specified one, Purpose is what Chrome sent before it and what some proxies still send. The match is on the token, so Sec-Purpose: prefetch;prerender — a prerender, which has the same problem — is covered as well.

Set it to false if you would rather count speculative loads.

// config/matomo-analytics.php
'middleware' => [
'skip_prefetch' => true,
],

The page reports itself when it is opened

Opt in and put the directive next to your tracking snippet:

// config/matomo-analytics.php
'prefetch_beacon' => [
'enabled' => true,
],
{{-- resources/views/layouts/app.blade.php --}}
@matomoScript
@matomoPrefetchPageView

The directive renders a small inline script that asks the browser one question — PerformanceNavigationTiming.deliveryType — and beacons the page view only when the answer is navigational-prefetch. An ordinary load has already been counted server-side, so it sends nothing; an engine that does not report deliveryType sends nothing either.

Running a strict Content Security Policy? The directive takes a nonce, like the others:

@matomoPrefetchPageView($nonce)

What the endpoint accepts

POST matomo-analytics/page-view takes the page URL and its title. The route is always registered — toggling the feature never needs a route-cache rebuild — and the controller answers 404 while the feature is off.

  • The URL must be on this application's own origin, compared on scheme, host and port. A form-encoded cross-origin POST needs no preflight, so any page anywhere could make its visitors beacon this route; a URL from somewhere else is refused with 422 rather than filed under one of your pages.
  • The title is optional and bounded. Without one the page is named by its path, which is what the server-side middleware does for a response that carries no <title>.
  • The hit goes through the normal tracking gate, so bots, opt-out, Do Not Track and excluded routes apply exactly as they do everywhere else.
  • The route is throttled per prefetch_beacon.throttle, keyed on the visitor's real address rather than on a proxy's.

Like the Web Vitals route, it sits in no middleware group, because sendBeacon() carries no CSRF token. No session is started there, so tracking.track_authenticated and tracking.except_abilities see a guest on this path. Name middleware in prefetch_beacon.middleware if you need those rules, and exempt the route from CSRF on your side.