Tracking Single Page Applications with Google Analytics


single page applications google analytics

Google Analytics and Google Tag Manager (GTM), though designed with traditional round-trip-based websites and web applications in mind, can be configured to work properly with single page applications (or SPAs). Common technical issues encountered when tracking SPAs with these tools are:

  • Only the first page is tracked
  • Page paths do not include fragment data (e.g. /app#/page-path is /app)
  • Page paths or page titles are incongruent with application state
  • Duplicate tracking of the first page
  • Misleading page timings data

These complications occur whether you’re using Angular, React, Backbone, or any other front-end framework or code that manipulates the History API or fragment alongside changes to on-page content.

There are also a few issues that arise specifically when using GTM to track your SPA.

  • Campaign information overriding
  • Accidental data inheritance
  • DOM state uncertainty

Let’s take a look at how to solve these issues.

A Note On Syntax

There are currently three supported syntaxes you might be using on your project. We’ll outline the steps for each, but make sure you’re using the correct one; in practice, these different syntaxes overlap. For example, Google Tag Manager (GTM), uses the global window.dataLayer as its interface, which gtag.js also uses albeit indirectly. Both GTM and gtag.js load analytics.js, the Google Analytics library. All of this interdependency can cause some confusion; make sure you’re only using one syntax.

Common Issues

Only the First Page Is Tracked

If you’ve already tried to implement Google Analytics, you’ve probably already noticed that only the first page view of your application is being recorded. You might have thought that Google Analytics had some mechanism to automagically track page views. There’s no magic here; GTM, analytics.js and gtag.js are just APIs for issuing page views and other hits, and the neat trick they’ve done is to embed a call to the page view method in the standard snippet the tool asks you to install on your site.

If you’re using analytics.js, it looks like this:

And if you’re using gtag.js, it looks like this:

If you’re using Google Tag Manager, it’s here:

For web sites where each page of content requires a round trip then result is that every time a page loads a page view gets sent to Google Analytics. Because your SPA doesn’t trigger a full round trip when content changes on the page you’ll need to add calls to your code when you want to track a page view. Often, we can do this automatically by binding to a routing handler in our code.

  • In Angular 1.X we can bind to $routeChangeSuccess or $stateChangeSuccess event on the $rootScope
  • With react-router we can extend Route with a component that calls our page view onComponentDidMount

Although automatic tracking can be helpful, consider what you are actually interested in measuring. Automatically triggered hits can be too frequent and become less meaningful. There are also limits on how many hits can be sent on a per-client and per-account basis.

  • 500 hits per session
  • 200,000 hits per user per day
  • 10,000,000 hits per month (unless you’re a GA360 customer)

An excellent implementation will track few things automatically. Here is the syntax to trigger a page view for each library:

If you’re using analytics.js:

If you’re using gtag.js:

If you’re using gtm.js:

GTM also requires you configure a Google Analytics Tag and a Trigger that fires on the custom event ‘pageview’:

Page Paths Do Not Include Fragment Data (e.g. /app#/page-path is /app)

Frameworks and code may leverage the fact that browsers will allow the hash or fragment to be changed without triggering a full page reload. Instead of changing the URL with the History API, the fragment is changed instead, usually with what appears to be a page path:


However, Google Analytics will not include the fragment in the Page dimension inside of the reports; instead, the above two paths would both be represented as /app.

To fix this issue, you’ll need to customize the data that you send to Google Analytics and “teach” the tool to include the fragment in the page path. For more on how to do this, read on.

Page Paths or Page Titles Are Incongruent with Application State

Often, we’ll want to adjust the page path or page title we send to Google Analytics. By default, Google Analytics will use the value of document.location.pathname and it will be stored as the Page dimension in Google Analytics:

You can override it with a path of your own. The value you set to the Page dimension must start with a forward slash (/) and should be formatted in the same manner as a page path.

You can also change the value for the Page Title dimension. By default, this will be whatever is in the <title> tag on the page at the time the hit is sent. If your application already changes the <title> tag when the state changes, you’re all set. If that’s not the case, see the below for examples on how to override that value.

A note: generally speaking, Google Analytics page-level reporting revolves around the Page dimension, and the Page Title dimension requires extra clicks to access or apply. As a result, we recommend orienting your implementation around the Page dimension and only using the Page Title to add additional context for specific reporting needs.

If you’re using analytics.js:

If you’re using gtag.js:

If you’re using GTM, you can use whatever dataLayer keys you wish; here’s a pattern we like:

Note that you’ll need to create Data Layer Variables to extract those values from the dataLayer, then set them in your Google Settings Variable (or directly on your tag). First, make a version 1 Data Layer Variable for page. Then create Custom JS variables to remove the values. For more on why you must take this approach, see Accidental Data Inheritance in the GTM-specific issues section below.

Then return the path property.

Duplicate Tracking of the First Page

Once you’ve enabled automatic page view tracking, on pages where your SPA is delivered alongside the standard header and footer content for your site you may start accidentally tracking two page views. The problem is that the Google Analytics snippet for the rest of the site fires its standard page view, then your application loads and also fires a page view. To fix the issue, you’ll need to either:

  1. Add logic to your backend that can detect when the SPA is shipping and remove the initial page view call
  2. Add logic to your SPA that detects that an initial page view has already been fired.

The first approach can be difficult to implement; maybe your application ships as part of the page and isn’t immediately bootstrapped, or that part of your templating pipeline doesn’t have knowledge of whether the page will ship the SPA or not.

The second approach can be simpler to implement:

But it can also present challenges and can feel a little gross. The best solution will depend on your codebase.

Misleading Page Timings Data

Google Analytics will track page speed timings for you and report on how long pages on your site took to load for visitors. Timing data is collected immediately after a page view hit is dispatched. The timing data is sourced from the window.performance API; because the page never reloads with a SPA, the net result is each page view after the first uses the same timing data as the first page view.

This can lead to bad analysis. Further complicating matters is the way in which timing hits are sampled.

This is a point of frustration with clients who often want insight into how performant their SPAs actually are. To solve for this, we recommend either:

  • Fishing out the data you need from performance.getEntries()
  • Storing a timestamp at an agreed upon pre-loading point, then capture the difference after a load has occurred

Additionally, we recommend using events to capture this data instead of timing hits.

Google Tag Manager-Specific Issues

Google Tag Manager has some specific design details that cause problems when trying to couple the tool with SPAs. Here are a few of those, and how to avoid them. If you’re not using GTM, you may skip the below.

Campaign Information Overriding

Due to the way that GTM handles issuing commands to Google Analytics, visits that include both an HTTP referrer value and special tracking parameters will be accidentally split into multiple sessions and incorrectly attributed inside the reports.

To fix this issue, import our SPA Campaign Information Fix recipe and set the customTask Field in your Google Settings Variable to {{JS - customTask - Null Conflicting Referrers}}. This will prevent this issue from impacting your container.

We’ve also got a SPA Container Bootstrap with all of the above setup for you right here.

Accidental Data Inheritance

GTM is designed to encourage code reuse; a page view can be triggered by many conditions (e.g. the page begins loading AND {event: 'pageview'}). This can lead to issues of accidental inheritance; a tag is fired more than once with data that it was only intended to use a single time, or stale data.

To prevent this, either use a clean-up tag to null sensitive keys or a “version 1” Data Layer Variable and Custom JS Variable.

Note: Unlike with version 2, you cannot access nested keys of version 1 Data Layer Variables. Instead, you must use a Custom JS variable to retrieve the value.

DOM State Uncertainty

The traditional round-trip model provides a simple lifecycle:

  1. The page is requested
  2. The initial HTML is received and the browser begins to parse the document into the DOM, requesting other resources as it parses them
  3. When initial parsing finishes, the document emits the DOMContentLoaded event
  4. When all resources have loaded, the window emits a load event

GTM listens for these lifecycle events and allows a user to trigger “tags” (code snippets, e.g. a Google Analytics page view) when they occur. Often, tags will require data stored somewhere in the DOM (e.g. the h3 of a widget) or will require the page be finished rendering before firing (e.g. scroll tracking). Because of this, we recommend adding a dataLayer.push() call when significant changes have completely finished rendering in your application.

In Closing

Remember to verify that your application:

  • Tracks page views on every meaningful state change
  • Correctly sets the page and title for the hit to values congruent with the state of the application
  • Does not fire two page views for the same page when the application first loads

Additionally, if you want to track page load times, use events and roll your own method of measurement. If you’re using GTM, consider adding a .push() after the DOM has finished rendering, and watch out for accidental data inheritance. Finally, make sure to import our campaign information overriding fix if you plan on using GTM, too.

Tracking SPAs with Google Analytics can be a lot of fun; because so much of the logic can live in the front end, it’s easy to add tracking with a deep knowledge of how the application works and the state that the application, session, and user are in at the time of data collection. Make sure to avoid these common pitfalls to enjoy a useful Google Analytics implementation.

Dan Wilkerson is a former LunaMetrician and contributor to our blog.

  • Awesome, as always, Dan, thx a lot

    • Dan Wilkerson

      Thanks Arne! Glad you enjoyed it.

  • Leonardo Lourenço Crespilho

    Hi Dan. Thank you for this awesome article.
    I have a question, though. đŸ˜‰

    Can you please explain a little bit more this statement?

    >> Due to the way that GTM handles issuing commands to Google Analytics, visits that include both an HTTP referrer value and special tracking parameters will be accidentally split into multiple sessions and incorrectly attributed inside the reports.

    How can I reproduce this issue?

    I really appreciate any help you can provide.

    • Dan Wilkerson

      Hi Leonardo,

      Sure thing; the issue arises when a user lands on the SPA with both a document.referrer value and UTM/gclid parameters in the URL. An example would be an AdWords ad; AdWords adds the gclid= parameter to the landing URL, which Google Analytics (GA) then captures to later join with AdWords data within your reports. The browser still sees a document.referrer value of, which would normally cause GA to attribute the session to Organic Search, but the presence of the gclid will override that behavior.

      The problem boils down to the document.referrer value never changing even as the user browses the site. Each hit sent to GA will contain the same referrer, but only the first will have the URL with the query parameters. Whenever a hit has a referrer that doesn’t match the attribution for a currently running session, GA kills the session and starts a new one with the document referrer value (google / organic in our above example).

      To reproduce, simply visit your SPA with a document.referrer value and UTM parameters in the URL, visit at least two pages, take note of the value of your GA cookie, and then review the sessions for that client ID in the User Explorer report.

      • Leonardo Lourenço Crespilho

        Hi Dan.
        Hmmm, ok. Thanks for the explanation. đŸ™‚

        I’m aware of the campaign processing flowchart ( and implemented a solution pretty similar to your customTask solution described on this article. By reading the phrase “due to the way that GTM handles issuing commands to GA”, I though that, maybe, GTM could use the library analytics.js in a way that could cause two sessions on GA with only one hit… and got scared. đŸ™‚

        Thank you so much!

        • Dan Wilkerson

          Ah, that refers to the fact that GTM creates a new tracker for each hit whereas hard-coded GA uses a single tracker for all hits. That’s part of the above issue.

          Per sessions/hits, that’s all serverside too; as long as the hit has the same Client ID and UA number and no new campaign info, it will be included in the same session.

          • Leonardo Lourenço Crespilho

            Thank you again, Dan.

            Great article and explanations. đŸ™‚

  • Tristan Bracamonte

    Hi Dan,

    I really like your article.

    I would like to add two thoughts:
    1. Handling dataLayer growth: the dataLayer can grow enormously as the dataLayer.push() usually just extends the object. So it needs to be reseted at certain events (e.g. pageviews)
    2. DOM growth: If you have implemented a few custom tags the scripts will extend the DOM every time they have been fired. My approach so far is to wrap these tags in a div class and execute a clean-up tag which removes the previous scripts.

    What do you think about that?


    • Dan Wilkerson

      Hi Tristan,

      Per #1, good news! GTM already handles that for you – after 300 events, it begins to shift our earlier events, capping the size of the queue. The internal dataLayers can also be reset by calling google_tag_manager[“GTM-XXXXX-YY”].dataLayer.reset(). Overall, I wouldn’t worry about it.

      Per #2, great minds think alike! I was just tossing around this idea a few years ago. Mind if I add your tips to my article with a shoutout?


      • Tristan Bracamonte

        #1 That’s indeed good news :).
        But for the reset function: It does not really empty the js dataLayer array, does it?

        #2 That is very interesting, yes sure.


        • Dan Wilkerson

          No, it just resets the internal dataLayer object, which could be useful if you’d like a “clean slate” between view changes and it should free up that memory. I wouldn’t worry about it, though; if performance in paramount, GTM shouldn’t be in the equation anyhow.

          • Tristan Hahn

            thanks, got it.

          • Darwin Gautalius

            Hi Dan.
            Thank you for sharing this great article.

            I think I have problem freeing up memory used by dataLayer in GTM.

            I have an SPA site. It will accumulate a lot of memory when a user navigate from a page to another page. I compared the memory consumed using Chrome Dev Tools’ heap snapshot by doing the following scenario:

            go to home page -> snapshot 1
            go to a sub page, and then go back to home page -> snapshot 2
            go to the same sub page, and then go back to home page -> snapshot 3
            go to the same sub page, and then go back to home page -> snapshot 4
            go to the same sub page, and then go back to home page -> snapshot 5

            (btw, I’m not an expert of this tools, so I don’t know whether what I did is correct or not to inspect memory usage)

            I did the scenario above twice: with GTM enabled and disabled, and it resulted in a big difference:
            – from snapshot 1 to 2, it increase the memory usage quite a lot. I’m fine with this because it probably loads a chunk script and the memory goes higher.
            – but for snapshot 2 to 3, 3 to 4, and 4 to 5, the difference is much smaller but consistent. This is where I see something strange between GTM enabled and disabled.
            – When enabled, I see increases in memory usage by around 3MB (I tested it in local development environment, around 1MB in production environment).
            – But with GTM disabled, it only hike the memory usage by around 0.1MB. (I also noticed that after idle for some times, the snapshot will show an even lower memory usage (probably due to GC execution). So, I don’t bother the increase of 0.1MB)

            My site uses GTM to track click event and enable built-in variable “Click Element” which will add gtm.element to dataLayer whenever a click event occured.
            I’m suspecting this variable because it stays in dataLayer forever until the browser refreshes. I also see that the element it kept also have a reference to its ancestors (dataLayer[n][‘gtm.element’].parentElement) until the ancestor element where it is replaced when a route changed (I use React btw)

            I tried several things to clear the dataLayer without any luck:
            – google_tag_manager[‘gtm id’].dataLayer.reset();
            – dataLayer.length = 0;
            – while (dataLayer.length) dataLayer.pop();

            Do you have any suggestion about how to free those data from memory? Thank you.


  • Vertical Life

    Hi Dan,

    Thanks for the article!
    I’m quickly reaching out in regard to the issue of “Campaign Information Overriding”. I’ve imported the container as per description and added the customTask Field for my virtual Pageview Tag, but unfortunately the issue with wrong attribution persists. Are there any modification of the code necessary or should it work out of the box? SPA Im on is based on vue.js

    Thanks in advance

    • Dan Wilkerson

      Yeah, that’s going to be a problem. You can only have one Custom Task and it expects a function; that’s going to pipe in some weirdness.

      Make a new Variable that imports and calls both of those functions.

      • Vertical Life

        Got it! Will try and amend accordingly. Thanks Dan

  • Chris Schraeder

    Hi Dan,

    great stuff. Another step would be how it all works out with enhanced ecommerce in addition.

Contact Us.

Follow Us



We'll get back to you
in ONE business day.
Our Locations
THE FOUNDRY [map] LunaMetrics

24 S. 18th Street
Suite 100

Pittsburgh, PA 15203


4115 N. Ravenswood
Suite 101
Chicago, IL 60613


2100 Manchester Rd.
Building C, Suite 1750
Wheaton, IL 60187