measurement

Analytics in a single-page app: the four things that break

React, Vue, Astro and the rest all break analytics in the same four ways. None are difficult to fix, and all four are silently wrong until somebody checks.

A single-page app loads once and then rewrites itself. Analytics built for documents that navigate by loading new documents gets four specific things wrong, and each one is silently wrong — the dashboard fills with plausible numbers that are not true.

One: only the first page is counted

The obvious one. A conventional snippet fires on page load. In an SPA there is one page load, then client-side route changes that fire nothing.

The result: your entry page has all the traffic, every other page has almost none, and your pages-per-visit is close to 1.0 no matter how much people read.

The fix. Most tools handle this now, but how matters. The fragile approach integrates with a specific router — a React Router hook, a Vue Router guard. It works until you upgrade the router or add a second one.

The durable approach hooks history.pushState and history.replaceState, which is the browser primitive every client-side router is built on, plus the popstate event for back and forward. That works with any framework, and with no framework, and does not care about versions.

Hash routers are the exception and need hashchange handled explicitly, because changing a fragment does not touch the history API in the same way.

Two: the referrer is wrong on every page after the first

Subtle and genuinely damaging.

On a real navigation, document.referrer is the previous page. In an SPA it never updates — it stays whatever it was when the app first loaded. Report it naively on every virtual pageview and every internal navigation is attributed to whatever brought the visitor to the site.

If they arrived from Google and then read six pages, you have recorded seven visits from Google. Your acquisition data is multiplied by however deep people browse, unevenly, in favour of whatever brings your most engaged readers.

The fix. Attribute the referrer to the visit, not to each pageview. The first pageview of a session carries the acquisition source; subsequent ones are internal and carry none. This is what "credited to the visit rather than the click" means, and it is also why numbers differ between tools that make different choices here.

Three: time on page is meaningless

Traditional time-on-page is inferred from the gap between two page loads. In an SPA there are no subsequent page loads, so either everything is zero or the whole session is attributed to one page.

Worse, request-gap timing was always a poor measure: it counts a tab left open in the background as engagement.

The fix. Measure engagement directly with the Page Visibility API — accumulate time only while the tab is visible, pause when it is hidden, and flush on visibilitychange. This is strictly better than request-gap timing even on traditional sites, and it is the only option that works at all here.

Four: the last pageview of a visit is lost

The exit page never sends anything, because there is no subsequent navigation and unload is unreliable — on mobile especially, where the browser may kill a backgrounded tab without firing anything.

The fix. navigator.sendBeacon on visibilitychange to hidden, not on unload. Beacons are queued by the browser and survive the page going away; unload handlers frequently do not run at all on mobile Safari.

Checking your own

Open your site, navigate through four or five routes, then look at your real-time view.

  • Did every route appear? If only the first did, problem one.
  • Does each show the correct path, or the entry path repeated?
  • Does the referrer appear once or five times? Five is problem two.
  • Close the tab. Did the last page register? If not, problem four.

Five minutes, and it is the difference between a dashboard you can act on and one that is confidently wrong in four directions at once.

The part an SPA cannot fix

Everything above is about the script working correctly. None of it helps when the script does not run at all — blocked, failed, or a crawler that executes no JavaScript.

For an SPA that is a sharper problem than usual, because SPA audiences skew technical and technical audiences block more. If your app's users are developers, the fraction you cannot see this way is not a rounding error, and the only complete answer is counting the initial request at the edge as well as the routes in the browser.

Common questions

Why does my SPA only record the first pageview?

Because client-side route changes do not trigger a page load, and a conventional snippet only fires on load. The durable fix is to hook history.pushState and history.replaceState — the browser primitives every router is built on — plus popstate for back and forward. Integrating with a specific router works too, but breaks on router upgrades.

Why does my SPA report the same referrer on every page?

Because document.referrer does not update on client-side navigation; it keeps the value from when the app first loaded. If the tool reports it on every virtual pageview, a visitor who arrives from Google and reads six pages is recorded as six Google visits. The fix is to attribute acquisition to the visit rather than to each pageview.

How should time on page be measured in a single-page app?

With the Page Visibility API, accumulating time only while the tab is actually visible and flushing on visibilitychange. Inferring it from the gap between page loads does not work in an SPA because there are no subsequent loads, and it was always a poor measure anyway — it counts a forgotten background tab as engagement.