Glossary

How SPA Tracking Counts Pageviews When the Page Never Reloads

Taras Shynkarenko
Taras Shynkarenko
•Updated: •7 min read
How SPA Tracking Counts Pageviews When the Page Never ReloadsHow SPA Tracking Counts Pageviews When the Page Never Reloads

TL;DR, Quick Answer

7 min read

A single-page application loads one HTML document and swaps screens with JavaScript, so a tag that only fires on page load records one pageview for the whole visit. SPA tracking fixes that with a virtual pageview on each route change, either from History API events (GA4's "Page changes based on browser history events" setting listens for pushState, popState and replaceState) or from manual page_view calls. Double counting comes from running two of these at once; missing pageviews come from routers that do not touch the URL or from pageviews sent before the URL updates.

What is SPA tracking?

In a single-page application, SPA tracking means recording a virtual pageview each time the app swaps screens with JavaScript, because the browser loads the HTML document once and never performs a new page load for later route changes. Google describes SPAs as "websites that load an HTML document once and fetch any additional content using JavaScript APIs," and sets the goal plainly: "The key to measuring page views from SPAs correctly is to count page views for each screen a user interacts with and get the page referrer right, so you can correctly trace the user journey."

A virtual pageview is a pageview event your analytics tool sends without a real page load. The browser never requests a new document, but the report shows a new page with its own URL and referrer, just as if it had.

Why does a standard analytics tag miss SPA pageviews?

A standard analytics tag misses SPA pageviews because it sends its pageview when the script first runs, and in an SPA the script runs once per visit. A visitor who lands on /, opens /pricing and then /signup through in-app links has made three screens' worth of decisions, while a load-only tag records one pageview for /.

The damage spreads past the pageview count. Google notes that without virtual pageviews "Google Analytics treats the SPA as a single page, leading to skewed metrics," and that "all engagement time is attributed to the initial page load, making it impossible to analyze time spent on individual screens." Clicks and scrolls get filed under the landing URL too. The pageviews vs sessions vs users guide explains why a low pageview count per session in an SPA is a tracking signal before it is a behaviour signal.

A wooden signpost at a crossroads with arrows pointing in different directions, standing in for the route changes a browser history records.

How does the History API signal a route change?

The History API signals a route change through methods your router calls and events the browser fires, and the two do not line up one to one. That mismatch is why trackers have to listen in more than one place.

ChangeWhat triggers itBrowser event fired
history.pushState()Your router, on an in-app linkNone
history.replaceState()Your router, when it rewrites the current URLNone
Back or forward buttonThe visitorpopstate
history.back(), forward(), go()Your codepopstate
Hash change (#section)A link or code that changes the fragmenthashchange

MDN is explicit on the first two rows: "just calling history.pushState() or history.replaceState() won't trigger a popstate event." A tracker that only listens for popstate sees back and forward clicks and misses every forward navigation through your own links. Trackers solve this by wrapping or observing pushState and replaceState directly.

How does GA4 track single-page applications?

GA4 tracks single-page applications through an enhanced measurement setting that sends a page_view when the browser history changes. Google's help center describes the page_view trigger as "each time the page loads or the browser history state is changed by the active site," and says the advanced setting "listens for pushState, popState, and replaceState." Google recommends this route "If your SPA uses the History API, specifically the pushState() and replaceState() method to update screens."

To turn it on:

  1. In Google Analytics, open Admin.
  2. Under Data collection and modification, click Data streams, then your web stream.
  3. Under Enhanced measurement, slide the switch on.
  4. Click the settings icon, and under Page views click Show advanced settings.
  5. Enable both Page loads and Page changes based on browser history events.
  6. Save.

The other route is manual. Google lists "Custom events" for sites where screens render without a URL change, such as an app that uses DocumentFragment objects. In that case you send gtag('event', 'page_view', { page_title: '...', page_location: '...' }) from your router yourself.

Which tracking route fits your SPA
Router uses pushState or replaceStateEnable Page changes based on browser history events in GA4
Tags run through Google Tag ManagerUse the History Change trigger and leave automatic history tracking off
Screens change with no URL changeSend page_view yourself as a custom event
Pick one route per app, because running two at once is what double counts.

What causes double counting in SPA tracking?

Double counting in SPA tracking happens when two mechanisms each send a pageview for the same route change. The common setups that cause it:

  • Google Tag Manager plus GA4 history events. Google's warning: "If you configure your tags using Google Tag Manager, don't enable automatic history tracking in Google Analytics, as this can lead to double-counting page views." A GTM History Change trigger already fires "when the URL fragment changes or when a site uses the HTML5 pushstate API."
  • Manual page_view calls plus the history setting. send_page_view: false does not stop history-based pageviews. Google states: "If Enhanced Measurement is enabled, Google Analytics will send page_view events based on browser history changes even if you set send_page_view: false." Turn off Page changes based on browser history events when your router sends its own pageviews.
  • replaceState used for filters or tabs. GA4 lists replaceState among the history changes it listens for, so a product list that writes ?sort=price into the URL with replaceState can produce pageviews for every filter change. Test this in DebugView before you trust page counts on filter-heavy screens.
  • Two Google tags with the same ID. A hard-coded gtag snippet and a GTM Google tag that point at the same measurement ID each send their own events.

What causes missing pageviews in SPA tracking?

Missing pageviews in SPA tracking come from route changes that never reach the History API, and from pageviews sent at the wrong moment.

  • Routers that never change the URL. An app that swaps components in memory gives a history listener nothing to hear. Use the custom event route.
  • Hash routing with a history-only tracker. Changing #/pricing fires hashchange. Check whether your tool counts fragment changes, and configure it if it does not.
  • Pageviews sent before the URL updates. A page_view that fires before pushState records the old page_location. In DebugView, Google advises comparing each page_view "with the preceding page_view event to check if the page referrer and page location have been updated correctly."
  • Stale referrers. Google's custom event example stores the old URL and passes it as page_referrer, because the browser's own document.referrer stays fixed at the original landing referrer for the whole visit.

One related trap sits outside the router. web.dev notes that the back/forward cache "doesn't work for 'soft navigations' within a single-page app," so a back click inside your app is a history event, while a back click that returns to your app from another site can restore the whole page from memory and fire no history event at all.

A developer at a desk clicking through a website on a laptop to check that each screen is recorded once, as the verification step requires.

How do you verify SPA tracking works?

You verify SPA tracking by clicking through the app with debug mode on and checking that each new screen produces exactly one pageview with the right location and referrer. Google's own check is to enable debug mode, "click through your single-page application," and confirm "a new page_view event in DebugView" for each virtual screen. Then repeat with the back and forward buttons, a filter change and a hash link, since those are the cases that double or drop. The analytics verification checklist applies the same idea to any tracker, and the average session duration guide shows what broken SPA tracking does to time-on-site numbers.

Flowsery
Flowsery

Start Your 14-Day Free Trial

Real-time dashboard

Goal tracking

Cookie-free tracking

Two smaller fixes help the numbers hold up. Reset the scroll position on each route change with window.scrollTo(0, 0) so scroll depth applies to the new screen rather than the old one, and send the pageview only after the router has updated the URL and the title.

How does Flowsery handle SPA navigation?

Flowsery tracks SPA navigation automatically, so a React Router or Next.js route change shows up as its own pageview without extra code. Flowsery's npm SDK docs list "pageviews, including SPA navigation" under automatic tracking, and its pricing FAQ states that Flowsery "counts a pageview each time a visitor loads a page on your site, including single-page app navigations." For apps that route with URL fragments, the npm SDK has a hashMode option, and the script docs describe a hash-routing version of the tracker for SPAs with hash routing. If your app changes screens without changing the URL, the SDK exposes trackPageview() so you can send one from your own code. The React analytics page covers how Flowsery measures client-side routes, custom events, funnels and revenue in React apps.

Frequently asked questions

What is a virtual pageview?

A virtual pageview is a pageview event sent without a real page load, used when an SPA changes screens through JavaScript. It carries its own page location and referrer, so reports treat each screen as a separate page. Without it, an SPA visit shows up as one long view of the landing page.

Does GA4 track SPA route changes automatically?

Yes, if enhanced measurement is on and Page changes based on browser history events is enabled under the Page views advanced settings. That setting listens for pushState, popState and replaceState. It does not help apps that change screens without changing the URL.

Why is GA4 counting every SPA pageview twice?

Two mechanisms are sending pageviews for the same change. The usual pairs are GTM history triggers with GA4 history events, or manual page_view calls with GA4 history events. Turn off one of them; Google states that send_page_view: false does not stop history-based pageviews.

Does pushState fire popstate?

No. MDN states that calling history.pushState() or history.replaceState() won't trigger a popstate event. Only browser actions such as the back and forward buttons, or calls like history.back(), fire it.

How should page_referrer work in an SPA?

Each virtual pageview should carry the previous screen's URL as its referrer. Store the old URL before the route change and pass it as page_referrer with the new page_location. Otherwise every screen reports the original landing referrer.

Does the back/forward cache affect SPA tracking?

Not for navigations inside the app. web.dev states that the bfcache does not work for soft navigations within an SPA. It does apply when a visitor leaves your app and comes back with the back button, which can restore the whole page from memory without a new load if the page is eligible.

How do I turn on SPA tracking in GA4?

Open Admin, click Data streams, choose your web stream and switch on Enhanced measurement. Then click the settings icon, open Show advanced settings under Page views and enable both Page loads and Page changes based on browser history events. Save. This route works when your router uses pushState and replaceState.

Should I enable GA4 history tracking if I use Google Tag Manager?

Leave it off. Google warns that using both can lead to double-counting page views. A GTM History Change trigger already fires when the URL fragment changes or when a site uses the HTML5 pushstate API.

How do I track hash routes like #/pricing in an SPA?

Changing the fragment fires hashchange, so a tracker that only watches the History API can miss it. Check whether your tool counts fragment changes and configure it if it does not. Flowsery's npm SDK has a hashMode option, and its script docs describe a hash-routing version of the tracker.

How can I check that my SPA sends one pageview per screen?

Turn on debug mode, click through the app and look for a new page_view event in DebugView for each screen. Compare each one with the preceding page_view to confirm the page location and referrer updated. Then repeat with the back and forward buttons, a filter change and a hash link.

Was This Article Helpful?

Let us know what you think!

See us more often in Google

One click marks Flowsery as a preferred source, so our articles sit higher in your Top Stories, AI Mode, and AI Overviews.

Before you go...

Flowsery

Flowsery

Revenue-first analytics for your website

Track every visitor, source, and conversion in real time. Simple, powerful, and cookie-free.

Real-time dashboard

Goal tracking

Cookie-free tracking

Related Glossary Terms

Related Articles