SEO

Web page loading time monitoring

Here’s the complete solution for tracking web page loading time in Google Tag Manager and Google Analytics – that works even on SPA websites!

Since Google Analytics switched from Universal to GA4, the page loading speed monitoring was no longer available in GA by default. So, we had to reproduce that functionality with Google Tag Manager using custom JavaScript. There are many code examples for achieving this for regular websites, but we haven’t found any that work with single-page application websites (SPA).

Long story short – after much vibe coding and flesh-n-blood developer verification, here’s the full JS code that works for both SPA and regular websites. You can just paste it into your GTM as a Custom HTML tag and trigger on Initialization – All Pages. The code pushes one event into the dataLayer with just one value, the calculated page loading time in seconds. It works for each page refresh, navigation, or URL history change.  We could get deep into a discussion about whether this tracks “true” page loading time, and we’re open to that discussion – that’s what the comment section is for. But as with anything else in web analytics, we believe this method is good enough to be directionally useful. We haven’t encountered any issues so far, so please let us know if you see any strange behavior.

Here’s the full code.  

HTML
<script>
(function () {
  // Prevent double setup
  if (window.__pageLoadTimingSetupDone) return;
  window.__pageLoadTimingSetupDone = true;

  var dataLayer = window.dataLayer = window.dataLayer || [];
  var perf = window.performance || window.webkitPerformance || window.msPerformance || window.mozPerformance;

  function toSecondsTwoDecimals(ms) {
    // ms -> seconds with 2 decimals
    return Math.round(ms / 10) / 100;
  }

  function pushTiming(ms, source) {
    if (!ms || ms <= 0) return;

    dataLayer.push({
      event: 'page_load_time_calc',
      page_load_timing: toSecondsTwoDecimals(ms),
      page_load_source: source
    });
  }

  /* -------------------------------
   *  INITIAL HARD PAGE LOAD
   * ----------------------------- */

  var initialSent = false;

  function calcInitialLoad() {
    if (initialSent || !perf) return;

    var ms = 0;

    // Navigation Timing v2
    if (perf.getEntriesByType && typeof perf.getEntriesByType === 'function') {
      var navEntries = perf.getEntriesByType('navigation');
      if (navEntries && navEntries.length) {
        var nav = navEntries[0];

        if (typeof nav.loadEventEnd === 'number' && nav.loadEventEnd > 0) {
          ms = nav.loadEventEnd; // since timeOrigin
        } else if (typeof nav.domComplete === 'number' && nav.domComplete > 0) {
          ms = nav.domComplete;
        }
      }
    }

    // Legacy Navigation Timing fallback
    if (!ms && perf.timing) {
      var t = perf.timing;
      if (t.loadEventEnd && t.navigationStart) {
        ms = t.loadEventEnd - t.navigationStart;
      } else if (t.domComplete && t.navigationStart) {
        ms = t.domComplete - t.navigationStart;
      }
    }

    if (ms && ms > 0) {
      initialSent = true;
      pushTiming(ms, 'initial');
    }
  }

  if (document.readyState === 'complete') {
    // Load already fired
    calcInitialLoad();
  } else {
    window.addEventListener('load', calcInitialLoad);
  }

  /* -------------------------------
   *  SPA NAVIGATION TIMING
   * ----------------------------- */

  if (!perf) return; // need perf for SPA timing

  var spaStart = null;
  var spaTimer = null;

  function nowMs() {
    if (perf.now && typeof perf.now === 'function') {
      return perf.now(); // relative to timeOrigin
    }
    return Date.now(); // fallback
  }

  function startSpaNav() {
    spaStart = nowMs();

    if (spaTimer) {
      clearTimeout(spaTimer);
      spaTimer = null;
    }

    // Heuristic: SPA "loaded" 1500ms after navigation
    spaTimer = setTimeout(function () {
      endSpaNav('spa_timeout');
    }, 1500);
  }

  function endSpaNav(reason) {
    if (spaStart == null) return;

    var duration = nowMs() - spaStart;
    spaStart = null;

    if (spaTimer) {
      clearTimeout(spaTimer);
      spaTimer = null;
    }

    if (duration > 0) {
      pushTiming(duration, 'spa');
    }
  }

  // Patch history API once to detect SPA route changes
  try {
    var originalPushState = history.pushState;
    history.pushState = function () {
      var rv = originalPushState && originalPushState.apply(this, arguments);
      startSpaNav();
      return rv;
    };

    var originalReplaceState = history.replaceState;
    history.replaceState = function () {
      var rv = originalReplaceState && originalReplaceState.apply(this, arguments);
      startSpaNav();
      return rv;
    };

    window.addEventListener('popstate', function () {
      startSpaNav();
    });
  } catch (e) {
    // fail silently if history patching isn't allowed
  }
})();

Leave a Reply

Your email address will not be published. Required fields are marked *