By Shehab Waleed, Founder, Cairo Studio 3 min read
Our site is a SvelteKit app. The first page comes from the server, and every page after that is rendered in the browser while the URL changes through the history API. The usual advice for this kind of site is to send a page_view yourself on each route change, because a classic analytics script only counts full page loads. We followed that advice.
GA4 was already doing it
GA4 has a setting called enhanced measurement, and one of its parts sends a page_view when the page changes based on browser history events. It is on by default for web streams. So each route change produced two page views: ours, and the one GA sent on its own.
We confirmed it on the live site in headless Chromium, with every request to Google blocked so no test traffic was recorded. One client side navigation produced one page_view from GA itself, without any call from us. Repeating the navigation with our old manual call added a second, identical hit for the same URL.
The fix
Our analytics loads late, for performance, so a few page views can happen before gtag.js exists. Those still need sending, and the gtag queue holds them until the script arrives. Once the script has loaded, route changes belong to GA.
ga.addEventListener('load', () => (gaLoaded = true));
function trackPageview() {
if (!gaLoaded) {
gtag('event', 'page_view', {
page_location: location.href,
page_title: document.title
});
}
}The queue only works if the stub pushes the arguments object. We learned that one separately, when our analytics snippet sent nothing for three weeks.
Late analytics loses quick visits
Loading analytics late has its own cost, and it took us a few versions to see it. We loaded every analytics script on the first interaction, or four seconds after the intro finished. A visitor who read the first screen for three seconds and left was never counted, because the script never arrived.
First interaction had a catch too. We listened for scroll, and the page fires scroll events by itself while it sets up, so the scripts loaded straight away on every visit. We changed the trigger to a wheel turn.
Our own counter, Umami, is small and self hosted, so it now loads at once and counts every visit. GA is heavier and still waits, which means it still misses the quickest visits. We accept that, because the two numbers answer different questions for us.
How to check yours
Open the network panel, filter for collect, and navigate between two pages without reloading. Each navigation should produce one request with en=page_view. Two means something on your side is also sending it.
