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
}
})();



