TL;DR, Quick Answer
7 min readA 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.
![]()
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.
| Change | What triggers it | Browser event fired |
|---|---|---|
history.pushState() | Your router, on an in-app link | None |
history.replaceState() | Your router, when it rewrites the current URL | None |
| Back or forward button | The visitor | popstate |
history.back(), forward(), go() | Your code | popstate |
Hash change (#section) | A link or code that changes the fragment | hashchange |
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:
- In Google Analytics, open Admin.
- Under Data collection and modification, click Data streams, then your web stream.
- Under Enhanced measurement, slide the switch on.
- Click the settings icon, and under Page views click Show advanced settings.
- Enable both Page loads and Page changes based on browser history events.
- 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.
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: falsedoes not stop history-based pageviews. Google states: "If Enhanced Measurement is enabled, Google Analytics will sendpage_viewevents based on browser history changes even if you setsend_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=priceinto the URL withreplaceStatecan 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
#/pricingfireshashchange. Check whether your tool counts fragment changes, and configure it if it does not. - Pageviews sent before the URL updates. A
page_viewthat fires beforepushStaterecords the oldpage_location. In DebugView, Google advises comparing eachpage_view"with the precedingpage_viewevent 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 owndocument.referrerstays 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.
![]()
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
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
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


Why Your GA4 Bounce Rate Is Just Engagement Rate Flipped
Your GA4 bounce rate is the share of sessions that were not engaged. How Google defines it, how it differs from Universal Analytics, and where to add it.


What the GA4 Landing Page Report Counts and Where It Goes Blank
Google's GA4 landing page report lists the first page of every session. Where to find it, its default metrics, why (not set) rows appear and how to add source.


How GA4 Site Search Tracking Records What Visitors Type
Set up GA4 site search with enhanced measurement, add your own query parameters, find the Search term dimension and track searches without a URL.


Setting GA4 User Properties and Reporting on Them
Set GA4 user properties with gtag or Tag Manager, register them as user-scoped custom dimensions and stay inside the 25-property limit.


How to Link Search Console to GA4 and Publish Its Reports
Learn how to link Search Console to GA4 in Admin, which roles you need, how to publish the two reports in Library and what the data can't join.


What the bfcache Does to Back Navigations and Your Pageview Counts
The bfcache, or back/forward cache, restores a page from memory on back and forward clicks. Which pages qualify, and why analytics miss or double count it.
Related Articles


How to Build GA4 Calculated Metrics Inside a Five-Slot Quota
Google caps GA4 calculated metrics at 5 per standard property. Learn the formula syntax, the units, where they appear and what a formula can't use.


Setting Up GA4 Content Grouping With One Parameter
Set up GA4 content grouping with the content_group parameter in gtag or Tag Manager, read the Content group dimension and keep (not set) rows out.


How to Add Google Analytics Annotations in GA4 and Keep a Change Log
In GA4, Google Analytics annotations pin a dated note to line graphs. Who can add them, the 60 and 150 character limits, the 1,000 cap, and change log use.