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.
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.
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
| Folder | Contains |
|---|---|
| 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
| Tag | Type | Fires On | Purpose |
|---|---|---|---|
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
| Trigger | Type | Conditions |
|---|---|---|
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.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).
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 Variable | dataLayer Path | GA4 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 |
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 Parameter | Source Variable | Measures |
|---|---|---|
| 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 Key | Timing API Calculation | UA Equivalent |
|---|---|---|
page_load_time | loadEventStart − navigationStart | Page Load Time |
page_download_time | responseEnd − responseStart | Page Download Time |
dns_time | domainLookupEnd − domainLookupStart | Domain Lookup Time |
redirect_response_time | fetchStart − navigationStart | Redirection Time |
server_response_time | responseStart − requestStart | Server Response Time |
tcp_connect_time | connectEnd − connectStart | Server Connection Time |
dom_interactive_time | domInteractive − navigationStart | Document Interactive Time |
content_load_time | domContentLoadedEventStart − navigationStart | Document 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) | Scope | Unit | Event Parameter |
|---|---|---|---|
| Page Load Time | Event | Milliseconds | e_pageload_ms |
| DOM Ready Time | Event | Milliseconds | e_domready_ms |
| Window Load Time | Event | Milliseconds | e_windowload_ms |
| Initialization Time | Event | Milliseconds | e_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:
- 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}}. - 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
- Download the JSON from the Google Drive link above.
- In GTM, go to Admin → Import Container.
- Upload
page-performance-measurement-recipe.json. - Choose Merge (not Overwrite) to preserve your existing container.
- Update
Measurement Stream ID ProductionandMeasurement Stream ID Development. - Adjust
siteSpeedSampleRatein the Page Performance Custom HTML tag if needed (line 3 of the script; range 1–100). - In GA4, create four custom metrics (event scope, Milliseconds unit) for
e_pageload_ms,e_domready_ms,e_windowload_ms, ande_initialization_ms. - Open GTM Preview mode, load any page, and confirm the
performance_timingcustom event appears in the Tag Assistant panel. Check that the four parameter values are positive integers above 10. - Confirm the Page Performance Send Event tag fires on the same event in Preview.
- Wait 24–48 hours for GA4 to begin populating the custom metrics in Explorations.
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
Post a Comment