Track Tab Visibility
Time on page metrics in GA4 are notoriously inaccurate — they measure time between events, not actual engagement time. When a user opens your page in a background tab and never looks at it, GA4 still counts that as engaged time. The Page Visibility API lets you track actual foreground visibility and measure genuine reading time.
The Page Visibility API
Section titled “The Page Visibility API”The Page Visibility API provides document.visibilityState with three values: visible, hidden, and prerender. The visibilitychange event fires whenever the state changes — when the user switches tabs, minimizes the browser, or locks their screen.
Implementation
Section titled “Implementation”Tracks tab visibility changes and calculates time spent in foreground.
(function() { var visibleStart = null; var totalVisibleTime = 0;
function getVisibleSeconds() { return Math.round(totalVisibleTime / 1000); }
function handleVisibilityChange() { if (document.visibilityState === 'hidden') { // Tab became hidden — record the session if (visibleStart !== null) { totalVisibleTime += Date.now() - visibleStart; visibleStart = null; }
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'tab_visibility', visibility_state: 'hidden', visible_seconds: getVisibleSeconds(), page_path: window.location.pathname, page_title: document.title }); } else if (document.visibilityState === 'visible') { // Tab became visible — start timer visibleStart = Date.now();
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'tab_visibility', visibility_state: 'visible', page_path: window.location.pathname }); } }
// Start timing if already visible on load if (document.visibilityState === 'visible') { visibleStart = Date.now(); }
document.addEventListener('visibilitychange', handleVisibilityChange);
// Report total visible time on page exit window.addEventListener('beforeunload', function() { if (visibleStart !== null) { totalVisibleTime += Date.now() - visibleStart; } if (getVisibleSeconds() > 0) { // Use sendBeacon for reliable delivery on page exit var payload = JSON.stringify({ event: 'page_engagement', visible_seconds: getVisibleSeconds(), page_path: window.location.pathname }); navigator.sendBeacon('/analytics-endpoint', payload); } });})();-
Add the code as a Custom HTML tag in GTM
- Trigger: DOM Ready (All Pages)
-
Create a Custom Event Trigger for visibility changes
- Trigger type: Custom Event
- Event name:
tab_visibility
-
Create a Custom Event Trigger for page engagement
- Trigger type: Custom Event
- Event name:
page_engagement
-
Create Data Layer Variables
DLV - visibility_state→visibility_stateDLV - visible_seconds→visible_seconds
-
Create two GA4 Event Tags
Tag 1 — Tab Visibility:
- Event name:
tab_visibility - Parameters:
visibility_state→{{DLV - visibility_state}}visible_seconds→{{DLV - visible_seconds}}
- Trigger:
tab_visibilityCustom Event trigger
Tag 2 — Page Engagement (for exit reporting):
- Event name:
page_engagement - Parameters:
visible_seconds→{{DLV - visible_seconds}}
- Trigger:
page_engagementCustom Event trigger
- Event name:
-
Test in Preview Mode
Open GTM Preview, visit a page, then switch to another tab. Within a second, a
tab_visibilityevent withvisibility_state: hiddenshould appear in the Summary pane.
GA4 - tab_visibility
- Type
- Google Analytics: GA4 Event
- Trigger
- Custom Event - tab_visibility
- Variables
-
DLV - visibility_stateDLV - visible_seconds
Simplified version — just track tab hide/show
Section titled “Simplified version — just track tab hide/show”If you only need to know when users switch away (without timing), the minimal implementation is:
document.addEventListener('visibilitychange', function() { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'tab_visibility', visibility_state: document.visibilityState, page_path: window.location.pathname });});Add this as a Custom HTML tag with a DOM Ready trigger.
Use cases
Section titled “Use cases”True engagement measurement. In GA4, create a custom metric for visible_seconds and use it instead of Engaged Sessions for content pages. It is far more accurate than GA4’s built-in engagement time calculation.
Exit intent complement. Combine with exit intent tracking: if a user hides the tab for more than 60 seconds before a purchase, flag them as at-risk and trigger a remarketing campaign.
A/B test accuracy. When running content A/B tests, use visible_seconds rather than time on page to measure which variant gets more genuine reading time.
Test it
Section titled “Test it”- Open GTM Preview, visit a content page
- Click on another browser tab
- Within 1 second, a
tab_visibilityevent withvisibility_state: hiddenshould appear - Switch back to your site — a
tab_visibilityevent withvisibility_state: visibleshould fire - Verify
visible_secondsaccumulates correctly on thehiddenevent
Common gotchas
Section titled “Common gotchas”beforeunload delivery is unreliable. Browsers can cancel beforeunload requests, especially on mobile. Use navigator.sendBeacon() for the exit event or accept that some page_engagement events will be lost. The tab_visibility events (hide/show) are reliable.
Multiple rapid tab switches inflate event count. If a user rapidly switches between tabs, you will get many tab_visibility events. Consider debouncing the push by 500ms if you only care about intentional tab changes.
Prerender state. Some browsers prerender pages before the user navigates to them. document.visibilityState will be prerender in this case. Guard against it by checking visibilityState !== 'prerender' before starting your timer.