Tracking Single Page Applications With Google Analytics

March 30, 2018
By Dan Wilkerson

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:

<script>
  (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

  ga('create', 'UA-0000000-1', 'auto');
  ga('send', 'pageview');
  ^^^^^^^^^^^^^^^^^^^^^^
</script>

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

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-57570530-1"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-0000000-1');
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
</script>

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

<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
                                                     ^^^^^^^^^^^^^^
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-0000000');</script>
<!-- End Google Tag Manager -->

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:

ga('send', 'pageview');

If you’re using gtag.js:

gtag('config', 'UA-0000000-1');

If you’re using gtm.js:

dataLayer.push({event: 'pageview'});

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:

/app#/page-path
/app#/subsequent-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:

ga('set', 'page', '/your-own-path');
ga('set', 'title', 'Your custom title');
ga('send', 'pageview');

If you’re using gtag.js:

gtag('config', 'UA-0000000-1', {
  'page_title' : 'Your custom title',
  'page_path': '/your-own-path'
});

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

dataLayer.push({
  event: 'pageview',
  page: {
    path: '/your-own-path',
    title: 'Your custom title'
  }
});

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:

let hasFired = false;

// Later in page view tracking function...
if (!hasFired) { hasFired = true; return; }
  
// Fire page view as normal now
...

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.

dataLayer.push({
  event: 'pageview',
  page: {
    path: '/some-page-path',
    title: 'Foo'
  }
});
// GA Hit has page=/some-page-path and title=Foo
// later on
dataLayer.push({
  event: 'pageview',
  page: {
    path: '/subsequent-page-path',
  }
});
// GA Hit has page=/subsequent-page-path AND ALSO title=Foo

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.

import React from 'react';

let dataLayer = window.dataLayer = window.dataLayer || [];

class Page extends React.Component {

  render() {

    return (
      
{this.props.children}

); } onComponentDidMount() { dataLayer.push({ event: 'domReady' }); } } export default Page;

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.