Skip to article body
A woman walking through an arched Cairo doorway, the slow shutter leaving overlapping copies of her figure
A woman walking through an arched Cairo doorway, the slow shutter leaving overlapping copies of her figure

Web Development · Field Notes

Every Second Page Counted Twice

GA4 already counts page changes in a single page app. We were counting them as well.

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.

Share
X LinkedIn Facebook WhatsApp Email
A raised hand and its doubled shadows on a bare wall

One visit, two hits.

Before
Our page_view, queued
After
Our page_view, queued
Before
Ours plus GA’s own, two hits
After
GA’s own, one hit
Before
Loaded late, missed quick visits
After
Loaded at once

Yes, when enhanced measurement is on and its page changes option is enabled, which is the default. GA4 sends a page_view when the URL changes through pushState or replaceState.

You can, and then send every page_view yourself. Either way works. Doing both is the mistake.

In the network panel, filter for collect and count requests with en=page_view after a navigation. In GA, a sudden jump in views per session after a code change is another sign.

We design and build fast, animated sites, and we test them on real phones before they ship.

See our web development work

More from the Journal

view all →