25 October 2026 · 9 min read

GA4 for Single-Page Apps: Why Route Changes Do Not Count

A single-page application loads one HTML document and fetches everything else over JavaScript. The address bar changes, the screen changes, and the browser does not load a new document. That last part is where measurement breaks, and it breaks quietly. The property looks healthy, events arrive, and the Pages and screens report shows one row with a few hundred thousand views against the home page.

Google can measure these applications properly and documents how. The problem is that two settings control the same event from opposite directions, and most broken setups have one of them right.

One document, many screens

Google's own definition: a single-page application is a website that loads an HTML document once and fetches additional content with JavaScript APIs. The example in the developer documentation is a three-screen lead form, first for contact details, second for service interest, third for a webinar signup. Three screens, one document, and to a browser that has not been told otherwise, one page.

The documentation states the goal plainly: count a page view for each screen a user interacts with, and get the referrer right, so the journey can be traced afterwards. Two routes are offered. If the application uses the History API, meaning pushState and replaceState, use browser history changes. If it renders screens with a DocumentFragment and never touches history, send custom events instead. The first route is the one Google recommends, and it also works without extra code, because enhanced measurement already listens for it.

What page_view actually listens for

The default page_view event fires each time a page loads, or when the browser history state is changed by the active site. The second half of that sentence exists for applications exactly like yours. The advanced setting behind it listens for pushState, popState and replaceState.

The event populates the Views metric and carries page_location, page_referrer and page_title. Collection of the event itself cannot be turned off. What can be turned off is the history half, and that switch is the source of most of the confusion below.

The setting that keeps counting behind your back

Teams that want control over page views usually reach for the Google tag config:

gtag('config', 'TAG_ID', { send_page_view: false });

That stops the view sent when the tag loads. It does not stop a view sent because the history state changed. Google says it directly: with Enhanced Measurement enabled, Analytics sends page_view on browser history changes even when send_page_view is false, because that listener runs independently of the config command.

So a team sets send_page_view: false, adds a manual view on every route change, and ships double the page views it expects. The fix sits in the property, not in the code: Admin, Data collection and modification, Data streams, the web stream, Enhanced measurement, Page views, Show advanced settings. Two switches live there, Page loads and Page changes based on browser history events. Set them to match the implementation. If the application already pushes history entries, leave the second one on and send nothing yourself.

One more detail from the same page: send_page_view does not persist across pages. Repeat it on the config command of every page where the automatic view should be suppressed.

Sending a view yourself, in gtag

When the app does not use the History API, or when a view has to be sent at a moment the framework controls, the call is short:

gtag('event', 'page_view', {
  page_title: 'Pricing after trial',
  page_location: 'https://example.com/app/pricing'
});

page_location must start with the protocol and carry the full URL. A relative path there produces a page value that will not group with anything else in reports.

The three config keys worth knowing are the defaults being overridden. page_title defaults to document.title, page_location defaults to location.href, and send_page_view defaults to true. Google's warning sits above the sample for a reason: disable the automatic views before sending manual ones.

The same job in Tag Manager, in six steps

The Tag Manager route is documented end to end, and it is the version most agency builds should copy because the tag list stays readable.

First, turn off automatic history-based page views in the property, following the path above. Second, enable the built-in history variables in the container: Variables, Configure, then the History section, where History New URL Fragment, History Old URL Fragment, History New State, History Old State and History Source all need ticking. Third, create a trigger: Triggers, New, Trigger Configuration, History Change. It fires on a URL fragment change or an HTML5 pushstate, and Google offers Some History Changes if only certain screens matter.

Fourth, create a Google tag using the same Tag ID as the main one, with configuration settings page_location set to {{Page URL}}, page_title set to {{Page Title}}, and update set to true. Fifth, create a GA4 Event tag with event name page_view and, under Advanced Settings, Tag Sequencing, set it to fire after that update tag. Sixth, verify in Preview and DebugView: one new page_view per screen change, with page_location values that match expectations.

What a missing view does to the numbers

Google is explicit about the consequence. If virtual page views are not recorded for screen changes, Analytics treats the application as a single page and the metrics skew. The clearest casualty is engagement. The user_engagement event measures active time on a page. Without route-level views, every minute a visitor spends moving through the app is booked against the page they first landed on, and no report can tell you which screen held their attention.

Session counting holds a second trap. In a report built on Page path and the Sessions metric, Sessions counts unique sessions in which that path was visited at least once, while Views counts loads. Google's example path is /home, then /products, then /home, then exit. The report shows one session and two views for /home, one session and one view for /products, and a deduplicated property total of one session and three views. Add the rows by hand and you get two sessions. For traffic that started somewhere, read Landing page rather than Sessions with page path, since that is where the session began.

Sessions do not restart on a route change

A route change does not open a new session. A session ends after 30 minutes of inactivity by default, and the timeout can be raised in the interface up to 7 hours and 55 minutes.

Route-level views still matter for session quality. An engaged session is one that lasts longer than ten seconds, contains two or more page views, or triggers a key event. An application that sends one view per journey can fail that test with a fully attentive visitor sitting on a screen.

Google is direct about what not to do next. Do not add a script that pings Analytics on a timer to keep sessions open. It creates zombie sessions, inflates average session duration, makes unengaged sessions look engaged, and adds event noise that costs money once the data reaches BigQuery. Where a visitor genuinely sits still, watching a 45 minute video or reading a long document, the documented pattern is a progress event carrying engagement_time_msec, started and stopped by a visibilitychange listener so it pauses when the tab is hidden.

Scroll position survives the route change

The browser keeps the scroll offset when the view swaps. A visitor who was 80 percent down a long article arrives halfway into the next screen, which is poor for them and worse for the data. Enhanced measurement sends scroll once, at 90 percent vertical depth, and a retained offset can fire that event on a page nobody read or suppress it on a page they did.

Google's recommendation is one line after each virtual page transition:

window.scrollTo(0, 0);

If the content sits in a scrolling container rather than the document, reset that element instead. Scroll depth numbers are only meaningful once each screen starts where a screen should start.

The title arrives late

The tag reads document.title and location.href at the moment it runs. In a framework router the URL usually changes first and the new title is written a tick later, so a view sent on the route change can carry the previous screen's name. Nothing errors. The Views metric is right and the page title is one screen behind.

Google's own verification step catches it, which is why it belongs in the process: compare the parameters of each page_view with the one before and check that page_referrer and page_location moved as expected. The fix is either to set the title in the router's after hook, or to pass an explicit page_title in the tag instead of relying on the default. Which one fits depends on the framework and on where the title comes from, so test it rather than assume.

A related issue Google documents is DOM rendering. If on-page search stops finding content after a virtual route, the framework is not updating the document synchronously, and the same delay is what makes tag timing unreliable.

A pre-launch check for any single-page app

Six checks, all in DebugView and the property interface, none of them needing a developer unless one fails.

Click through three screens and count the page_view events. There should be three, one per screen, with no duplicates. Compare page_location and page_referrer between consecutive events and confirm they describe the screen just left. Open Enhanced measurement and read the state of both switches under Page views, then confirm it matches whether your code sends views or the property does. Look at Pages and screens for one row carrying most of the traffic, the signature of an app measured as a single page. Read the Landing page report separately, since it answers a different question from the page path table. Then load a long screen, change route, and watch whether scroll depth behaves.

If the property fails any of those, the fix is a setting and a tag, not a rebuild. Most single-page apps that report badly are one toggle away from reporting well.

If your app reports one page and a six figure view count against it, book a free tracking audit and we will trace where the views went missing.