Skip to content

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 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.

dataLayer.push() tab_visibility

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);
}
});
})();
  1. Add the code as a Custom HTML tag in GTM

    • Trigger: DOM Ready (All Pages)
  2. Create a Custom Event Trigger for visibility changes

    • Trigger type: Custom Event
    • Event name: tab_visibility
  3. Create a Custom Event Trigger for page engagement

    • Trigger type: Custom Event
    • Event name: page_engagement
  4. Create Data Layer Variables

    • DLV - visibility_statevisibility_state
    • DLV - visible_secondsvisible_seconds
  5. 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_visibility Custom Event trigger

    Tag 2 — Page Engagement (for exit reporting):

    • Event name: page_engagement
    • Parameters:
      • visible_seconds{{DLV - visible_seconds}}
    • Trigger: page_engagement Custom Event trigger
  6. Test in Preview Mode

    Open GTM Preview, visit a page, then switch to another tab. Within a second, a tab_visibility event with visibility_state: hidden should appear in the Summary pane.

Tag Configuration

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.

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.

  1. Open GTM Preview, visit a content page
  2. Click on another browser tab
  3. Within 1 second, a tab_visibility event with visibility_state: hidden should appear
  4. Switch back to your site — a tab_visibility event with visibility_state: visible should fire
  5. Verify visible_seconds accumulates correctly on the hidden event

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.