GA4 Page Performance - Bring Back Site Speed

Page Performance Timing in GA4 via GTM — Bringing Back Site Speed Without Universal Analytics

Send eight browser performance timing metrics to Google Analytics 4 on every sampled page load, using GTM's Window Loaded trigger, a Custom HTML timing script, and a custom event — no additional tools required.

Published · Tags: GTM, Google Analytics 4, Performance Timing, Navigation Timing API, Site Speed, dataLayer, Custom Metrics, BigQuery

Original concept and code: This recipe is a rewrite and GTM container implementation of the performance timing approach originally published by David Vallejo (@thyngster) at thyngster.com — Performance Timing tracking with Google Analytics 4 (July 2022). David's article remains the authoritative reference for the original technique, the sampling logic, and the GA4 custom metric definitions. This post documents a ready-to-import GTM container that packages his approach into a deployable recipe.

One of the most frequently missed features after migrating from Universal Analytics to GA4 is the Site Speed report. UA tracked eight browser timing metrics out of the box — page load time, DNS lookup, server response, and more — and surfaced them in a dedicated report. GA4 ships with none of this by default.

The good news is that the browser's window.performance.timing API (part of the Navigation Timing Level 1 specification) is still available in every modern browser, and GTM's Window Loaded trigger fires at exactly the right moment to read it. This recipe collects all eight timing metrics, pushes them to the dataLayer, guards against negative or zero values that indicate a timing anomaly, applies the same client-ID-based sampling logic that Universal Analytics used, and sends a clean page_performance event to GA4 with four parameters that you register as custom millisecond metrics.

⬇ Download the GTM Container JSON from Google Drive

What's Inside the Container

The exported container (page-performance-measurement-recipe.json) is organised into two folders, three tags, two triggers, twenty-five variables, and three community Custom Templates.

Folders

FolderContains
Analytics GA4 configuration tag, shared settings variables, environment/stream routing, Browser Client ID, session and timestamp utilities, Root Domain helper.
Page Performance The Custom HTML timing tag, the GA4 send event tag, the custom event trigger, and the four dataLayer variables that bridge the timing push to the GA4 tag.

Tags

TagTypeFires OnPurpose
Google Analytics Configuration Google Tag (googtag) All Pages (built-in) Standard GA4 config tag with environment-aware stream ID routing.
Page Performance Custom HTML Window Loaded Reads window.performance.timing, applies sampling, performs sanity checks, and pushes a performance_timing custom event to the dataLayer.
Page Performance Send Event GA4 Event (gaawe) Page Performance Send Event Trigger (custom event) Listens for the performance_timing dataLayer event and forwards four timing metrics to GA4 as a page_performance event with millisecond parameters.

Triggers

TriggerTypeConditions
Window Loaded Window Loaded No additional filter. Fires after all resources on the page have finished loading — the correct moment to read loadEventStart from the timing API.
Page Performance Send Event Trigger Custom Event Event name equals performance_timing AND all four metrics match ^[1-9][0-9].* — meaning each value must be at least 10 ms. This prevents the GA4 tag from firing when the timing script ran but produced zero or sub-10 ms values, which typically indicate cached or pre-rendered pages where timing data is unreliable.

The Timing Script: How It Works

The Page Performance Custom HTML tag contains a self-executing function that runs at Window Load. Here is the full script with commentary:

<script>
// Performance Timing GTM Custom HTML Javascript Tag
// Rewrite by Claude.ai of https://www.thyngster.com/performance-timing-tracking-with-google-analytics-4

(function() {
  var siteSpeedSampleRate = 100;   // 100 = measure every visitor; lower to reduce volume
  var gaCookieName = '_ga';
  var dataLayerName = 'dataLayer';

  // ── Sampling ──────────────────────────────────────────────────────
  // Replicates Universal Analytics' siteSpeedSampleRate logic.
  // Hashes the GA client ID and checks whether it falls within the
  // sample bucket. Deterministic: the same visitor is always in or
  // always out for a given rate.
  var hashId = function(a) {
    var b = 1, c;
    if (a) {
      for (b = 0, c = a.length - 1; 0 <= c; c--) {
        var d = a.charCodeAt(c);
        b = (b << 6 & 268435455) + d + (d << 14);
        d = b & 266338304;
        b = d ? b ^ d >> 21 : b;
      }
    }
    return b;
  };

  var rate = siteSpeedSampleRate > 100 ? 100 : siteSpeedSampleRate < 1 ? 1 : siteSpeedSampleRate;
  var cookieMatch = ('; ' + document.cookie).split('; ' + gaCookieName + '=').pop()
                     .split(';').shift().split(/GA\d\.\d\./);
  var clientId = cookieMatch.length > 1 ? cookieMatch[1] : null;

  if (!clientId || hashId(clientId) % 100 >= rate) return;  // not in sample — exit

  // ── Timing API ────────────────────────────────────────────────────
  var pt = (window.performance || window.webkitPerformance);
  pt = pt && pt.timing;

  if (!pt || !pt.navigationStart || !pt.loadEventStart) return;  // API unavailable or page not fully loaded

  var ns = pt.navigationStart;
  var timingData = {
    page_load_time:         pt.loadEventStart             - ns,  // total time to loadEventStart
    page_download_time:     pt.responseEnd                - pt.responseStart,  // bytes transfer time
    dns_time:               pt.domainLookupEnd            - pt.domainLookupStart,
    redirect_response_time: pt.fetchStart                 - ns,  // redirect overhead
    server_response_time:   pt.responseStart              - pt.requestStart,
    tcp_connect_time:       pt.connectEnd                 - pt.connectStart,
    dom_interactive_time:   pt.domInteractive             - ns,  // time to DOM ready
    content_load_time:      pt.domContentLoadedEventStart - ns   // time to DOMContentLoaded
  };

  // ── Sanity check: abort if any metric is negative ─────────────────
  // Negative values occur when the timing API is partially reset (e.g.
  // bfcache navigations, some SPAs). Sending negative milliseconds to
  // GA4 would corrupt metric averages.
  for (var key in timingData) {
    if (timingData[key] < 0) return;
  }

  // ── Push to dataLayer ─────────────────────────────────────────────
  var dl = window[dataLayerName];
  if (dl) dl.push({ event: 'performance_timing', timing: timingData });

}());
</script>
Window Loaded vs DOM Ready: The script fires on GTM's Window Loaded trigger (equivalent to the browser's window.onload event) because pt.loadEventStart is only populated after the load event has fired. Using DOM Ready would cause loadEventStart to read as 0, making the sanity check !pt.loadEventStart bail out immediately.

Sampling: Replicating Universal Analytics' siteSpeedSampleRate

Universal Analytics sampled site speed hits using a deterministic hash of the client ID so that the same visitor was consistently included or excluded — avoiding session-level inconsistency where a user's first page was sampled but their second was not. This recipe replicates that exact logic.

The hashId() function produces an integer from the client ID string. The result modulo 100 gives a value in the range 0–99. If that value is less than siteSpeedSampleRate, the visitor is in the sample and the timing data is collected. At a rate of 100 (the default in this container), every visitor is included. At a rate of 10, approximately 10% of visitors are measured.

The cookie parsing uses a regex compatible with both GA1.2. and GA4.2. style client ID prefixes (the original Thyngster script used a GA1.[0-9]\. pattern; this rewrite generalises to GA\d\.\d\. to accommodate multi-domain tagging configurations that produce different domain depth prefixes).

Adjust the sample rate for high-traffic sites: At siteSpeedSampleRate = 100 every page load sends a page_performance event. On GA4 free properties, which have a 1,000 events-per-session cap before sampling, this adds one event to every session. For sites with very high page-view volume consider setting the rate to 10–50. The trade-off is wider confidence intervals on average timing metrics in GA4 Explorations and BigQuery.

The dataLayer Push

When sampling and sanity checks pass, the script pushes this object to the dataLayer:

{
  event: 'performance_timing',
  timing: {
    page_load_time:         131,   // ms from navigationStart to loadEventStart
    page_download_time:       0,   // ms to transfer response bytes
    dns_time:                 0,   // ms for DNS resolution
    redirect_response_time:   1,   // ms of redirect overhead
    server_response_time:    34,   // ms from request sent to first byte
    tcp_connect_time:         0,   // ms to establish TCP connection
    dom_interactive_time:    63,   // ms to DOM interactive
    content_load_time:       63    // ms to DOMContentLoaded
  }
}

All eight metrics are present in the push, but only four are forwarded to GA4 in this recipe. The four dataLayer variables read from the timing.* path and are filtered by the send event trigger before any hit leaves the browser.

The Four dataLayer Variables

GTM VariabledataLayer PathGA4 Parameter Name
Page Performance dataLayer page_load_time timing.page_load_time e_pageload_ms
Page Performance dataLayer content_load_time timing.content_load_time e_windowload_ms
Page Performance dataLayer page_download_time timing.page_download_time e_initialization_ms
Page Performance dataLayer dom_interactive_time timing.dom_interactive_time e_domready_ms
Why only four parameters? GA4's free tier allows up to 50 custom event parameters per event, but registering custom metric definitions in the GA4 property UI takes deliberate effort and namespace. The four parameters selected — page load, DOMContentLoaded, DOM interactive, and page download — cover the metrics most directly equivalent to the UA Site Speed report's headline figures. The remaining four (dns_time, redirect_response_time, server_response_time, tcp_connect_time) are available in the dataLayer push and can be added to additional GTM variables and GA4 parameters without changing the script.

The Send Event Trigger: Double-Gating on Data Quality

The Page Performance Send Event Trigger applies a second layer of data quality filtering beyond the script's own sanity check. In addition to matching the custom event name performance_timing, it requires that all four forwarded metrics match the regex ^[1-9][0-9].*:

  • The pattern requires the value to start with a non-zero digit followed by at least one more digit — meaning the metric must be ≥ 10 ms.
  • Metrics of 0–9 ms are excluded. Single-digit values can arise from cached resources, service worker interceptions, or HTTP/2 push where timing boundaries collapse to near-zero. These readings are real but skew average metrics significantly when mixed with genuine navigation timings, and are excluded here for analytical cleanliness.
  • All four parameters must pass simultaneously. If any one of the four key metrics is below 10 ms, the entire event is suppressed.

GA4 Event Parameters Sent

GA4 ParameterSource VariableMeasures
e_initialization_ms {{Page Performance dataLayer page_download_time}} Time in ms to transfer the response body bytes (responseEnd − responseStart)
e_pageload_ms {{Page Performance dataLayer page_load_time}} Total time in ms from navigation start to loadEventStart — the closest equivalent to UA's Page Load Time metric
e_domready_ms {{Page Performance dataLayer dom_interactive_time}} Time in ms from navigation start to the DOM becoming interactive (domInteractive − navigationStart)
e_windowload_ms {{Page Performance dataLayer content_load_time}} Time in ms from navigation start to DOMContentLoaded firing (domContentLoadedEventStart − navigationStart)

All Eight Timing Metrics Available in the dataLayer

The script computes and pushes all eight metrics even though only four are currently forwarded to GA4. The full set mirrors the metrics David Vallejo documented in the original Thyngster article:

Metric KeyTiming API CalculationUA Equivalent
page_load_timeloadEventStart − navigationStartPage Load Time
page_download_timeresponseEnd − responseStartPage Download Time
dns_timedomainLookupEnd − domainLookupStartDomain Lookup Time
redirect_response_timefetchStart − navigationStartRedirection Time
server_response_timeresponseStart − requestStartServer Response Time
tcp_connect_timeconnectEnd − connectStartServer Connection Time
dom_interactive_timedomInteractive − navigationStartDocument Interactive Time
content_load_timedomContentLoadedEventStart − navigationStartDocument Content Loaded Time

Registering GA4 Custom Metrics

Sending event parameters to GA4 does not automatically make them available in reports. You must register each parameter as a custom metric in the GA4 property with event scope and Milliseconds as the unit of measurement. This allows GA4 Explorations and the standard reports to compute averages correctly.

In GA4, go to Admin → Custom definitions → Custom metrics and create:

Metric Name (suggested)ScopeUnitEvent Parameter
Page Load TimeEventMillisecondse_pageload_ms
DOM Ready TimeEventMillisecondse_domready_ms
Window Load TimeEventMillisecondse_windowload_ms
Initialization TimeEventMillisecondse_initialization_ms

If you choose to add the remaining four timing metrics from the dataLayer push, add additional GTM variables for timing.dns_time, timing.redirect_response_time, timing.server_response_time, and timing.tcp_connect_time, wire them into the GA4 event tag, and register them as custom metrics with millisecond units in GA4.

Querying the Data in BigQuery

Once GA4 is linked to BigQuery, performance data becomes available for flexible querying. As David Vallejo showed in the original article, you can identify slow pages directly:

-- Pages where page load exceeded 1 second
SELECT
  event_date,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page,
  (SELECT value.int_value   FROM UNNEST(event_params) WHERE key = 'e_pageload_ms')  AS page_load_ms
FROM `your_project.your_dataset.events_*`
WHERE event_name = 'page_performance'
  AND (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'e_pageload_ms') > 1000
ORDER BY page_load_ms DESC
-- Average timing metrics by page path
SELECT
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page,
  ROUND(AVG(
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'e_pageload_ms')
  )) AS avg_page_load_ms,
  ROUND(AVG(
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'e_domready_ms')
  )) AS avg_dom_ready_ms,
  COUNT(*) AS samples
FROM `your_project.your_dataset.events_*`
WHERE event_name = 'page_performance'
GROUP BY page
ORDER BY avg_page_load_ms DESC

Environment-Aware Stream Routing

Like the other recipes in this series, the container routes traffic to development or production GA4 properties automatically:

  1. Measurement Stream RegEx Lookup checks {{Page URL}} against non-production hostname patterns (dev|uat|qa|orig|stg|stage|staging|int|local|admin|aem, plus the GTM Preview appspot.com domain). Matches route to {{Measurement Stream ID Development}}.
  2. Environment Stream ID checks the GTM {{Environment Name}} built-in. Live environments match ^liv.*; pre-production matches ^(pre|de|st|ua|qa|te).*. The default is {{Measurement Stream ID Production}}.

Update the Measurement Stream ID Production (placeholder: G-AAAAAAAA) and Measurement Stream ID Development (placeholder: G-ZZZZZZZZ) Constant variables before publishing.

How to Import

  1. Download the JSON from the Google Drive link above.
  2. In GTM, go to Admin → Import Container.
  3. Upload page-performance-measurement-recipe.json.
  4. Choose Merge (not Overwrite) to preserve your existing container.
  5. Update Measurement Stream ID Production and Measurement Stream ID Development.
  6. Adjust siteSpeedSampleRate in the Page Performance Custom HTML tag if needed (line 3 of the script; range 1–100).
  7. In GA4, create four custom metrics (event scope, Milliseconds unit) for e_pageload_ms, e_domready_ms, e_windowload_ms, and e_initialization_ms.
  8. Open GTM Preview mode, load any page, and confirm the performance_timing custom event appears in the Tag Assistant panel. Check that the four parameter values are positive integers above 10.
  9. Confirm the Page Performance Send Event tag fires on the same event in Preview.
  10. Wait 24–48 hours for GA4 to begin populating the custom metrics in Explorations.
Navigation Timing Level 1 deprecation: The window.performance.timing API (Navigation Timing Level 1) is deprecated in favour of the Navigation Timing Level 2 PerformanceNavigationTiming interface accessible via performance.getEntriesByType('navigation')[0]. Level 1 remains supported in all major browsers as of mid-2025, but a future version of this recipe will migrate to Level 2 for forward compatibility. The core timing calculations and metric names will not change.

The full source — container JSON, script commentary, and variable inventory — is available on GitHub. Credit and gratitude to David Vallejo (@thyngster) for the original technique and for sharing it openly. If you extend the metric set, add Level 2 API support, or adapt the sampling logic, open a pull request or issue.

⬇ Download GTM Container JSON

Comments

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies