Web APIs

JavaScript Page Visibility API: Complete Tab State Management Guide

Master the Page Visibility API in JavaScript for detecting tab visibility changes. Learn to pause/resume operations and optimize performance.

By JavaScriptDoc Team
visibilityperformancetabsoptimizationjavascript

JavaScript Page Visibility API: Complete Tab State Management Guide

The Page Visibility API provides events and properties to detect when a webpage becomes visible or hidden, enabling you to pause expensive operations, save battery life, and improve user experience.

Understanding the Page Visibility API

The Page Visibility API allows you to know when a user switches tabs, minimizes the browser, or switches applications, helping you optimize resource usage.

// Check Page Visibility API support
if ('hidden' in document) {
  console.log('Page Visibility API is supported');
} else {
  console.log('Page Visibility API is not supported');
}

// Get current visibility state
console.log('Document hidden:', document.hidden);
console.log('Visibility state:', document.visibilityState);

// Listen for visibility changes
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    console.log('Page is hidden');
  } else {
    console.log('Page is visible');
  }
});

// Visibility states
// 'visible' - Page is in foreground tab
// 'hidden' - Page is in background tab or minimized
// 'prerender' - Page is being prerendered (deprecated)

Basic Visibility Management

Visibility State Manager

class VisibilityManager {
  constructor() {
    this.callbacks = new Map();
    this.state = {
      visible: !document.hidden,
      visibilityState: document.visibilityState,
      lastHiddenTime: null,
      lastVisibleTime: Date.now(),
      totalHiddenTime: 0,
      totalVisibleTime: 0,
    };

    this.init();
  }

  // Initialize visibility tracking
  init() {
    // Check for API support with vendor prefixes
    this.hiddenProperty = this.getHiddenProperty();
    this.visibilityChangeEvent = this.getVisibilityChangeEvent();

    if (this.hiddenProperty) {
      document.addEventListener(
        this.visibilityChangeEvent,
        this.handleVisibilityChange.bind(this)
      );
    }

    // Track initial state
    if (!this.isVisible()) {
      this.state.lastHiddenTime = Date.now();
      this.state.lastVisibleTime = null;
    }
  }

  // Get hidden property with vendor prefix
  getHiddenProperty() {
    const prefixes = ['', 'webkit', 'moz', 'ms'];

    for (const prefix of prefixes) {
      const property = prefix ? `${prefix}Hidden` : 'hidden';
      if (property in document) {
        return property;
      }
    }

    return null;
  }

  // Get visibility change event with vendor prefix
  getVisibilityChangeEvent() {
    const prefixes = ['', 'webkit', 'moz', 'ms'];

    for (const prefix of prefixes) {
      const event = `${prefix}visibilitychange`;
      if (`on${event}` in document) {
        return event;
      }
    }

    return 'visibilitychange';
  }

  // Check if page is visible
  isVisible() {
    return !document[this.hiddenProperty];
  }

  // Get visibility state
  getVisibilityState() {
    return (
      document.visibilityState ||
      document.webkitVisibilityState ||
      document.mozVisibilityState ||
      document.msVisibilityState ||
      (this.isVisible() ? 'visible' : 'hidden')
    );
  }

  // Handle visibility change
  handleVisibilityChange() {
    const wasVisible = this.state.visible;
    const isNowVisible = this.isVisible();

    this.state.visible = isNowVisible;
    this.state.visibilityState = this.getVisibilityState();

    const now = Date.now();

    if (isNowVisible && !wasVisible) {
      // Page became visible
      if (this.state.lastHiddenTime) {
        const hiddenDuration = now - this.state.lastHiddenTime;
        this.state.totalHiddenTime += hiddenDuration;
      }

      this.state.lastVisibleTime = now;
      this.notifyCallbacks('visible', {
        timestamp: now,
        hiddenDuration: now - (this.state.lastHiddenTime || now),
      });
    } else if (!isNowVisible && wasVisible) {
      // Page became hidden
      if (this.state.lastVisibleTime) {
        const visibleDuration = now - this.state.lastVisibleTime;
        this.state.totalVisibleTime += visibleDuration;
      }

      this.state.lastHiddenTime = now;
      this.notifyCallbacks('hidden', {
        timestamp: now,
        visibleDuration: now - (this.state.lastVisibleTime || now),
      });
    }

    // Always notify change event
    this.notifyCallbacks('change', {
      visible: isNowVisible,
      state: this.state.visibilityState,
      timestamp: now,
    });
  }

  // Subscribe to visibility events
  on(event, callback) {
    if (!this.callbacks.has(event)) {
      this.callbacks.set(event, new Set());
    }

    this.callbacks.get(event).add(callback);

    return () => {
      const callbacks = this.callbacks.get(event);
      if (callbacks) {
        callbacks.delete(callback);
      }
    };
  }

  // Notify callbacks
  notifyCallbacks(event, data) {
    const callbacks = this.callbacks.get(event);
    if (callbacks) {
      callbacks.forEach((callback) => {
        try {
          callback(data);
        } catch (error) {
          console.error('Visibility callback error:', error);
        }
      });
    }
  }

  // Get time spent visible/hidden
  getTimeSpent() {
    const now = Date.now();
    let totalVisible = this.state.totalVisibleTime;
    let totalHidden = this.state.totalHiddenTime;

    if (this.state.visible && this.state.lastVisibleTime) {
      totalVisible += now - this.state.lastVisibleTime;
    } else if (!this.state.visible && this.state.lastHiddenTime) {
      totalHidden += now - this.state.lastHiddenTime;
    }

    return {
      visible: totalVisible,
      hidden: totalHidden,
      total: totalVisible + totalHidden,
      visiblePercentage: (totalVisible / (totalVisible + totalHidden)) * 100,
    };
  }

  // Execute callback when visible
  whenVisible(callback) {
    if (this.isVisible()) {
      callback();
    } else {
      const unsubscribe = this.on('visible', () => {
        callback();
        unsubscribe();
      });
    }
  }

  // Execute callback with visibility-aware throttling
  throttleWhenHidden(callback, visibleDelay = 1000, hiddenDelay = 5000) {
    let timeoutId;

    const execute = () => {
      callback();

      const delay = this.isVisible() ? visibleDelay : hiddenDelay;
      timeoutId = setTimeout(execute, delay);
    };

    execute();

    // Adjust timing on visibility change
    const unsubscribe = this.on('change', ({ visible }) => {
      clearTimeout(timeoutId);
      const delay = visible ? visibleDelay : hiddenDelay;
      timeoutId = setTimeout(execute, delay);
    });

    // Return cleanup function
    return () => {
      clearTimeout(timeoutId);
      unsubscribe();
    };
  }
}

// Usage
const visibility = new VisibilityManager();

// Listen for visibility changes
visibility.on('visible', ({ hiddenDuration }) => {
  console.log(`Page visible after ${hiddenDuration}ms`);
});

visibility.on('hidden', ({ visibleDuration }) => {
  console.log(`Page hidden after ${visibleDuration}ms visible`);
});

// Get time statistics
setInterval(() => {
  const timeSpent = visibility.getTimeSpent();
  console.log('Time spent:', timeSpent);
}, 10000);

// Execute when visible
visibility.whenVisible(() => {
  console.log('Page is now visible, starting operation...');
});

Resource Management

Auto-Pause Manager

class AutoPauseManager {
  constructor() {
    this.visibility = new VisibilityManager();
    this.pauseableResources = new Map();
    this.settings = {
      pauseVideos: true,
      pauseAnimations: true,
      pauseTimers: true,
      pauseRequests: true,
      throttleRequests: false,
    };

    this.init();
  }

  // Initialize auto-pause
  init() {
    this.visibility.on('hidden', () => {
      this.pauseAll();
    });

    this.visibility.on('visible', () => {
      this.resumeAll();
    });

    // Setup resource monitors
    if (this.settings.pauseVideos) {
      this.monitorVideos();
    }

    if (this.settings.pauseAnimations) {
      this.monitorAnimations();
    }
  }

  // Register pauseable resource
  register(id, resource) {
    this.pauseableResources.set(id, {
      resource,
      paused: false,
      pauseMethod: resource.pause || null,
      resumeMethod: resource.resume || resource.play || null,
    });

    // Return unregister function
    return () => this.unregister(id);
  }

  // Unregister resource
  unregister(id) {
    return this.pauseableResources.delete(id);
  }

  // Pause all resources
  pauseAll() {
    this.pauseableResources.forEach((item, id) => {
      if (!item.paused) {
        this.pauseResource(id);
      }
    });

    this.pauseGlobalResources();
  }

  // Resume all resources
  resumeAll() {
    this.pauseableResources.forEach((item, id) => {
      if (item.paused) {
        this.resumeResource(id);
      }
    });

    this.resumeGlobalResources();
  }

  // Pause specific resource
  pauseResource(id) {
    const item = this.pauseableResources.get(id);
    if (!item || item.paused) return;

    try {
      if (item.pauseMethod) {
        item.pauseMethod.call(item.resource);
      } else if (item.resource.pause) {
        item.resource.pause();
      }

      item.paused = true;
      console.log(`Paused resource: ${id}`);
    } catch (error) {
      console.error(`Failed to pause resource ${id}:`, error);
    }
  }

  // Resume specific resource
  resumeResource(id) {
    const item = this.pauseableResources.get(id);
    if (!item || !item.paused) return;

    try {
      if (item.resumeMethod) {
        item.resumeMethod.call(item.resource);
      } else if (item.resource.play) {
        item.resource.play().catch(() => {});
      }

      item.paused = false;
      console.log(`Resumed resource: ${id}`);
    } catch (error) {
      console.error(`Failed to resume resource ${id}:`, error);
    }
  }

  // Pause global resources
  pauseGlobalResources() {
    // Pause CSS animations
    if (this.settings.pauseAnimations) {
      document.body.style.animationPlayState = 'paused';

      // Create style to pause all animations
      if (!this.pauseStyle) {
        this.pauseStyle = document.createElement('style');
        this.pauseStyle.id = 'auto-pause-animations';
        document.head.appendChild(this.pauseStyle);
      }

      this.pauseStyle.textContent = `
        *, *::before, *::after {
          animation-play-state: paused !important;
          transition: none !important;
        }
      `;
    }
  }

  // Resume global resources
  resumeGlobalResources() {
    // Resume CSS animations
    if (this.settings.pauseAnimations) {
      document.body.style.animationPlayState = 'running';

      if (this.pauseStyle) {
        this.pauseStyle.textContent = '';
      }
    }
  }

  // Monitor videos
  monitorVideos() {
    // Watch for new videos
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeName === 'VIDEO') {
            this.registerVideo(node);
          }
        });
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    // Register existing videos
    document.querySelectorAll('video').forEach((video) => {
      this.registerVideo(video);
    });
  }

  // Register video for auto-pause
  registerVideo(video) {
    const id = `video-${Date.now()}-${Math.random()}`;

    // Store original autoplay state
    const wasAutoplay = video.autoplay;

    const videoResource = {
      pause: () => {
        if (!video.paused) {
          video.pause();
          video.dataset.wasPlaying = 'true';
        }
      },
      play: () => {
        if (video.dataset.wasPlaying === 'true') {
          video.play().catch(() => {
            // Handle autoplay restrictions
            console.log('Could not resume video playback');
          });
          delete video.dataset.wasPlaying;
        }
      },
    };

    this.register(id, videoResource);

    // Clean up when video is removed
    const observer = new MutationObserver(() => {
      if (!document.contains(video)) {
        this.unregister(id);
        observer.disconnect();
      }
    });

    observer.observe(video.parentNode || document.body, {
      childList: true,
    });
  }

  // Monitor animations
  monitorAnimations() {
    // Web Animations API
    if ('getAnimations' in document) {
      // Periodically check for new animations
      setInterval(() => {
        if (!this.visibility.isVisible()) {
          document.getAnimations().forEach((animation) => {
            if (animation.playState === 'running') {
              animation.pause();

              // Mark for resume
              if (!this.pausedAnimations) {
                this.pausedAnimations = new Set();
              }
              this.pausedAnimations.add(animation);
            }
          });
        } else if (this.pausedAnimations) {
          // Resume paused animations
          this.pausedAnimations.forEach((animation) => {
            if (animation.playState === 'paused') {
              animation.play();
            }
          });
          this.pausedAnimations.clear();
        }
      }, 1000);
    }
  }
}

// Usage
const autoPause = new AutoPauseManager();

// Register custom resource
const myAnimation = {
  isRunning: true,
  pause() {
    this.isRunning = false;
    console.log('Animation paused');
  },
  resume() {
    this.isRunning = true;
    console.log('Animation resumed');
  },
};

const unregister = autoPause.register('my-animation', myAnimation);

// Configure settings
autoPause.settings.pauseVideos = true;
autoPause.settings.pauseAnimations = true;

Performance Optimization

Request Manager

class VisibilityAwareRequestManager {
  constructor() {
    this.visibility = new VisibilityManager();
    this.pendingRequests = new Map();
    this.activeRequests = new Map();
    this.requestQueue = [];
    this.config = {
      maxConcurrent: 5,
      maxConcurrentHidden: 1,
      delayWhenHidden: 5000,
      priorityWhenVisible: true,
    };

    this.init();
  }

  // Initialize request management
  init() {
    this.visibility.on('change', ({ visible }) => {
      if (visible) {
        this.processQueue();
      } else {
        this.throttleRequests();
      }
    });
  }

  // Make a request
  async request(url, options = {}) {
    const request = {
      id: this.generateId(),
      url,
      options,
      priority: options.priority || 'normal',
      timestamp: Date.now(),
      retries: 0,
      maxRetries: options.maxRetries || 3,
    };

    // High priority requests bypass visibility check
    if (request.priority === 'high' || this.visibility.isVisible()) {
      return this.executeRequest(request);
    } else {
      return this.queueRequest(request);
    }
  }

  // Execute request
  async executeRequest(request) {
    const controller = new AbortController();
    request.controller = controller;

    this.activeRequests.set(request.id, request);

    try {
      const response = await fetch(request.url, {
        ...request.options,
        signal: controller.signal,
      });

      this.activeRequests.delete(request.id);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      return response;
    } catch (error) {
      this.activeRequests.delete(request.id);

      // Retry logic
      if (error.name !== 'AbortError' && request.retries < request.maxRetries) {
        request.retries++;

        // Exponential backoff
        const delay = Math.min(1000 * Math.pow(2, request.retries), 30000);

        await new Promise((resolve) => setTimeout(resolve, delay));

        return this.executeRequest(request);
      }

      throw error;
    }
  }

  // Queue request
  queueRequest(request) {
    return new Promise((resolve, reject) => {
      request.resolve = resolve;
      request.reject = reject;

      this.requestQueue.push(request);
      this.pendingRequests.set(request.id, request);

      // Process queue if visible
      if (this.visibility.isVisible()) {
        this.processQueue();
      }
    });
  }

  // Process request queue
  async processQueue() {
    const maxConcurrent = this.visibility.isVisible()
      ? this.config.maxConcurrent
      : this.config.maxConcurrentHidden;

    while (
      this.requestQueue.length > 0 &&
      this.activeRequests.size < maxConcurrent
    ) {
      const request = this.requestQueue.shift();
      this.pendingRequests.delete(request.id);

      try {
        const response = await this.executeRequest(request);
        request.resolve(response);
      } catch (error) {
        request.reject(error);
      }
    }
  }

  // Throttle requests when hidden
  throttleRequests() {
    // Pause low priority requests
    this.activeRequests.forEach((request) => {
      if (request.priority === 'low' && request.controller) {
        request.controller.abort();
        this.queueRequest(request);
      }
    });
  }

  // Generate unique ID
  generateId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  // Cancel all pending requests
  cancelAll() {
    // Cancel active requests
    this.activeRequests.forEach((request) => {
      if (request.controller) {
        request.controller.abort();
      }
    });

    // Reject pending requests
    this.pendingRequests.forEach((request) => {
      request.reject(new Error('Request cancelled'));
    });

    this.activeRequests.clear();
    this.pendingRequests.clear();
    this.requestQueue = [];
  }

  // Get request statistics
  getStats() {
    return {
      active: this.activeRequests.size,
      pending: this.pendingRequests.size,
      queued: this.requestQueue.length,
      total: this.activeRequests.size + this.pendingRequests.size,
    };
  }
}

// Polling Manager
class VisibilityAwarePolling {
  constructor(callback, options = {}) {
    this.callback = callback;
    this.visibility = new VisibilityManager();
    this.options = {
      interval: 5000,
      hiddenInterval: 30000,
      immediate: true,
      ...options,
    };

    this.timeoutId = null;
    this.isRunning = false;
  }

  // Start polling
  start() {
    if (this.isRunning) return;

    this.isRunning = true;

    if (this.options.immediate) {
      this.execute();
    } else {
      this.scheduleNext();
    }

    // Adjust interval on visibility change
    this.unsubscribe = this.visibility.on('change', () => {
      if (this.isRunning) {
        clearTimeout(this.timeoutId);
        this.scheduleNext();
      }
    });
  }

  // Stop polling
  stop() {
    this.isRunning = false;

    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }

    if (this.unsubscribe) {
      this.unsubscribe();
    }
  }

  // Execute callback
  async execute() {
    if (!this.isRunning) return;

    try {
      await this.callback();
    } catch (error) {
      console.error('Polling callback error:', error);
    }

    this.scheduleNext();
  }

  // Schedule next execution
  scheduleNext() {
    if (!this.isRunning) return;

    const interval = this.visibility.isVisible()
      ? this.options.interval
      : this.options.hiddenInterval;

    this.timeoutId = setTimeout(() => {
      this.execute();
    }, interval);
  }

  // Update intervals
  updateIntervals(visibleInterval, hiddenInterval) {
    this.options.interval = visibleInterval;
    this.options.hiddenInterval = hiddenInterval;

    // Reschedule if running
    if (this.isRunning) {
      clearTimeout(this.timeoutId);
      this.scheduleNext();
    }
  }
}

// Usage
const requestManager = new VisibilityAwareRequestManager();

// Make requests
requestManager
  .request('/api/data', { priority: 'normal' })
  .then((response) => response.json())
  .then((data) => console.log('Data:', data));

// High priority request (always executes)
requestManager
  .request('/api/critical', { priority: 'high' })
  .then((response) => console.log('Critical data received'));

// Polling example
const polling = new VisibilityAwarePolling(
  async () => {
    const response = await fetch('/api/status');
    const status = await response.json();
    console.log('Status:', status);
  },
  {
    interval: 5000, // 5 seconds when visible
    hiddenInterval: 60000, // 1 minute when hidden
  }
);

polling.start();

Analytics and Engagement Tracking

Engagement Analytics

class EngagementTracker {
  constructor() {
    this.visibility = new VisibilityManager();
    this.metrics = {
      pageViews: 0,
      totalTimeSpent: 0,
      activeTimeSpent: 0,
      hiddenTimeSpent: 0,
      visibilityChanges: 0,
      lastInteraction: Date.now(),
      interactions: 0,
      scrollDepth: 0,
    };

    this.sessionStart = Date.now();
    this.isActive = true;
    this.activityTimeout = null;

    this.init();
  }

  // Initialize tracking
  init() {
    // Track visibility changes
    this.visibility.on('change', ({ visible }) => {
      this.metrics.visibilityChanges++;

      if (!visible) {
        this.handlePageHidden();
      } else {
        this.handlePageVisible();
      }
    });

    // Track user activity
    this.trackUserActivity();

    // Track scroll depth
    this.trackScrollDepth();

    // Send analytics periodically
    this.startAnalyticsReporting();

    // Handle page unload
    window.addEventListener('beforeunload', () => {
      this.sendAnalytics(true);
    });
  }

  // Track user activity
  trackUserActivity() {
    const activityEvents = [
      'mousedown',
      'mousemove',
      'keypress',
      'scroll',
      'touchstart',
      'click',
    ];

    const handleActivity = () => {
      this.metrics.lastInteraction = Date.now();
      this.metrics.interactions++;

      if (!this.isActive) {
        this.isActive = true;
        this.onUserActive();
      }

      // Reset activity timeout
      clearTimeout(this.activityTimeout);
      this.activityTimeout = setTimeout(() => {
        this.isActive = false;
        this.onUserInactive();
      }, 30000); // 30 seconds of inactivity
    };

    // Throttle activity tracking
    let activityThrottle;
    const throttledActivity = () => {
      if (!activityThrottle) {
        activityThrottle = setTimeout(() => {
          handleActivity();
          activityThrottle = null;
        }, 1000);
      }
    };

    activityEvents.forEach((event) => {
      document.addEventListener(event, throttledActivity, { passive: true });
    });
  }

  // Track scroll depth
  trackScrollDepth() {
    let maxScroll = 0;

    const updateScrollDepth = () => {
      const windowHeight = window.innerHeight;
      const documentHeight = document.documentElement.scrollHeight;
      const scrollTop =
        window.pageYOffset || document.documentElement.scrollTop;

      const scrollPercentage =
        ((scrollTop + windowHeight) / documentHeight) * 100;

      maxScroll = Math.max(maxScroll, scrollPercentage);
      this.metrics.scrollDepth = Math.round(maxScroll);
    };

    // Throttle scroll tracking
    let scrollThrottle;
    window.addEventListener(
      'scroll',
      () => {
        if (!scrollThrottle) {
          scrollThrottle = setTimeout(() => {
            updateScrollDepth();
            scrollThrottle = null;
          }, 200);
        }
      },
      { passive: true }
    );

    // Initial measurement
    updateScrollDepth();
  }

  // Handle page hidden
  handlePageHidden() {
    this.hiddenStartTime = Date.now();

    // Pause active time tracking
    if (this.activeStartTime) {
      this.metrics.activeTimeSpent += Date.now() - this.activeStartTime;
      this.activeStartTime = null;
    }
  }

  // Handle page visible
  handlePageVisible() {
    if (this.hiddenStartTime) {
      this.metrics.hiddenTimeSpent += Date.now() - this.hiddenStartTime;
      this.hiddenStartTime = null;
    }

    // Resume active time tracking
    if (this.isActive) {
      this.activeStartTime = Date.now();
    }
  }

  // Handle user active
  onUserActive() {
    if (this.visibility.isVisible()) {
      this.activeStartTime = Date.now();
    }
  }

  // Handle user inactive
  onUserInactive() {
    if (this.activeStartTime) {
      this.metrics.activeTimeSpent += Date.now() - this.activeStartTime;
      this.activeStartTime = null;
    }
  }

  // Calculate engagement score
  calculateEngagementScore() {
    const timeSpent = this.visibility.getTimeSpent();
    const totalTime = Date.now() - this.sessionStart;

    // Factors for engagement score
    const factors = {
      timeOnPage: Math.min(timeSpent.visible / 60000, 10), // Cap at 10 minutes
      activeTime: Math.min(this.metrics.activeTimeSpent / 60000, 10),
      interactions: Math.min(this.metrics.interactions / 10, 10),
      scrollDepth: this.metrics.scrollDepth / 10,
      visibilityRatio: (timeSpent.visible / totalTime) * 10,
    };

    // Calculate weighted score
    const weights = {
      timeOnPage: 0.2,
      activeTime: 0.3,
      interactions: 0.2,
      scrollDepth: 0.2,
      visibilityRatio: 0.1,
    };

    let score = 0;
    for (const [factor, value] of Object.entries(factors)) {
      score += value * weights[factor];
    }

    return Math.round(score * 10); // Score out of 100
  }

  // Get current metrics
  getMetrics() {
    const timeSpent = this.visibility.getTimeSpent();
    const totalTime = Date.now() - this.sessionStart;

    // Update current times
    if (this.activeStartTime) {
      this.metrics.activeTimeSpent += Date.now() - this.activeStartTime;
      this.activeStartTime = Date.now();
    }

    return {
      ...this.metrics,
      sessionDuration: totalTime,
      visibleTime: timeSpent.visible,
      hiddenTime: timeSpent.hidden,
      visibilityPercentage: timeSpent.visiblePercentage,
      engagementScore: this.calculateEngagementScore(),
      timestamp: Date.now(),
    };
  }

  // Start analytics reporting
  startAnalyticsReporting() {
    // Report every 30 seconds when visible
    this.reportingPoller = new VisibilityAwarePolling(
      () => {
        this.sendAnalytics();
      },
      {
        interval: 30000, // 30 seconds when visible
        hiddenInterval: 300000, // 5 minutes when hidden
      }
    );

    this.reportingPoller.start();
  }

  // Send analytics
  async sendAnalytics(isUnload = false) {
    const metrics = this.getMetrics();

    try {
      if (isUnload && navigator.sendBeacon) {
        // Use sendBeacon for unload events
        const data = JSON.stringify(metrics);
        navigator.sendBeacon('/api/analytics', data);
      } else {
        // Regular fetch
        await fetch('/api/analytics', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(metrics),
        });
      }

      console.log('Analytics sent:', metrics);
    } catch (error) {
      console.error('Failed to send analytics:', error);
    }
  }

  // Track custom event
  trackEvent(eventName, data = {}) {
    const event = {
      name: eventName,
      data,
      timestamp: Date.now(),
      visible: this.visibility.isVisible(),
      active: this.isActive,
    };

    // Send immediately for important events
    if (data.immediate) {
      this.sendEvent(event);
    }

    return event;
  }

  // Send custom event
  async sendEvent(event) {
    try {
      await fetch('/api/events', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(event),
      });
    } catch (error) {
      console.error('Failed to send event:', error);
    }
  }
}

// Usage
const tracker = new EngagementTracker();

// Track custom events
tracker.trackEvent('video-play', {
  videoId: '123',
  duration: 180,
});

// Get current metrics
const metrics = tracker.getMetrics();
console.log('Engagement metrics:', metrics);

// Monitor engagement score
setInterval(() => {
  const score = tracker.calculateEngagementScore();
  console.log('Engagement score:', score);

  if (score < 30) {
    console.log('Low engagement detected');
    // Show re-engagement prompt
  }
}, 60000);

Notification Management

Smart Notifications

class SmartNotificationManager {
  constructor() {
    this.visibility = new VisibilityManager();
    this.notifications = new Map();
    this.queue = [];
    this.settings = {
      showWhenHidden: true,
      queueWhenHidden: true,
      soundWhenHidden: true,
      maxQueued: 10,
    };

    this.init();
  }

  // Initialize notification manager
  async init() {
    // Request notification permission if needed
    if ('Notification' in window && Notification.permission === 'default') {
      await Notification.requestPermission();
    }

    // Handle visibility changes
    this.visibility.on('visible', () => {
      this.processQueue();
    });

    // Handle notification clicks
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.addEventListener('message', (event) => {
        if (event.data.type === 'notification-click') {
          this.handleNotificationClick(event.data.id);
        }
      });
    }
  }

  // Show notification
  async show(title, options = {}) {
    const notification = {
      id: this.generateId(),
      title,
      options: {
        icon: '/icon-192.png',
        badge: '/badge-72.png',
        timestamp: Date.now(),
        requireInteraction: false,
        ...options,
      },
      shown: false,
      clicked: false,
    };

    this.notifications.set(notification.id, notification);

    // Determine if should show immediately
    if (this.shouldShowImmediately(notification)) {
      return this.showNotification(notification);
    } else {
      return this.queueNotification(notification);
    }
  }

  // Determine if notification should show immediately
  shouldShowImmediately(notification) {
    if (this.visibility.isVisible()) {
      // Show in-page notification when visible
      return true;
    }

    if (!this.settings.showWhenHidden) {
      return false;
    }

    if (notification.options.priority === 'high') {
      return true;
    }

    return !this.settings.queueWhenHidden;
  }

  // Show notification
  async showNotification(notification) {
    notification.shown = true;

    if (this.visibility.isVisible()) {
      // Show in-page notification
      return this.showInPageNotification(notification);
    } else {
      // Show system notification
      return this.showSystemNotification(notification);
    }
  }

  // Show in-page notification
  showInPageNotification(notification) {
    const element = document.createElement('div');
    element.className = 'in-page-notification';
    element.innerHTML = `
      <div class="notification-content">
        ${
          notification.options.icon
            ? `<img src="${notification.options.icon}" class="notification-icon">`
            : ''
        }
        <div class="notification-text">
          <div class="notification-title">${notification.title}</div>
          ${
            notification.options.body
              ? `<div class="notification-body">${notification.options.body}</div>`
              : ''
          }
        </div>
        <button class="notification-close">&times;</button>
      </div>
    `;

    // Apply styles
    this.applyInPageStyles();

    // Add to page
    document.body.appendChild(element);

    // Auto-hide after duration
    const duration = notification.options.duration || 5000;
    const hideTimeout = setTimeout(() => {
      element.classList.add('hiding');
      setTimeout(() => element.remove(), 300);
    }, duration);

    // Handle close button
    element
      .querySelector('.notification-close')
      .addEventListener('click', () => {
        clearTimeout(hideTimeout);
        element.remove();
      });

    // Handle click
    element.addEventListener('click', (e) => {
      if (!e.target.classList.contains('notification-close')) {
        this.handleNotificationClick(notification.id);
        element.remove();
      }
    });

    return notification;
  }

  // Show system notification
  async showSystemNotification(notification) {
    if (!('Notification' in window)) {
      return this.showInPageNotification(notification);
    }

    if (Notification.permission !== 'granted') {
      return null;
    }

    try {
      const systemNotification = new Notification(notification.title, {
        ...notification.options,
        tag: notification.id,
      });

      systemNotification.onclick = () => {
        this.handleNotificationClick(notification.id);
        window.focus();
        systemNotification.close();
      };

      // Play sound if enabled
      if (this.settings.soundWhenHidden && notification.options.sound) {
        this.playSound(notification.options.sound);
      }

      return notification;
    } catch (error) {
      console.error('Failed to show system notification:', error);
      return null;
    }
  }

  // Queue notification
  queueNotification(notification) {
    if (this.queue.length >= this.settings.maxQueued) {
      this.queue.shift(); // Remove oldest
    }

    this.queue.push(notification);
    return notification;
  }

  // Process notification queue
  processQueue() {
    const toShow = [...this.queue];
    this.queue = [];

    toShow.forEach((notification, index) => {
      setTimeout(() => {
        this.showNotification(notification);
      }, index * 500); // Stagger notifications
    });
  }

  // Handle notification click
  handleNotificationClick(id) {
    const notification = this.notifications.get(id);
    if (!notification) return;

    notification.clicked = true;

    // Execute click action
    if (notification.options.onClick) {
      notification.options.onClick();
    }

    // Navigate to URL
    if (notification.options.url) {
      window.location.href = notification.options.url;
    }

    // Track click
    this.trackNotificationEvent('click', notification);
  }

  // Track notification event
  trackNotificationEvent(event, notification) {
    if (window.analytics) {
      window.analytics.track('Notification Event', {
        event,
        notificationId: notification.id,
        title: notification.title,
        shown: notification.shown,
        clicked: notification.clicked,
      });
    }
  }

  // Play notification sound
  playSound(soundUrl) {
    const audio = new Audio(soundUrl);
    audio.volume = 0.5;
    audio.play().catch(() => {
      console.log('Could not play notification sound');
    });
  }

  // Apply in-page notification styles
  applyInPageStyles() {
    if (document.getElementById('notification-styles')) return;

    const style = document.createElement('style');
    style.id = 'notification-styles';
    style.textContent = `
      .in-page-notification {
        position: fixed;
        top: 20px;
        right: 20px;
        max-width: 350px;
        background: white;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        padding: 16px;
        z-index: 10000;
        animation: slideIn 0.3s ease-out;
      }

      @keyframes slideIn {
        from {
          transform: translateX(100%);
          opacity: 0;
        }
        to {
          transform: translateX(0);
          opacity: 1;
        }
      }

      .in-page-notification.hiding {
        animation: slideOut 0.3s ease-out;
      }

      @keyframes slideOut {
        to {
          transform: translateX(100%);
          opacity: 0;
        }
      }

      .notification-content {
        display: flex;
        align-items: start;
        gap: 12px;
      }

      .notification-icon {
        width: 40px;
        height: 40px;
        border-radius: 8px;
      }

      .notification-text {
        flex: 1;
      }

      .notification-title {
        font-weight: 600;
        margin-bottom: 4px;
      }

      .notification-body {
        font-size: 14px;
        color: #666;
      }

      .notification-close {
        background: none;
        border: none;
        font-size: 24px;
        cursor: pointer;
        padding: 0;
        width: 24px;
        height: 24px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #999;
      }

      .notification-close:hover {
        color: #333;
      }
    `;

    document.head.appendChild(style);
  }

  // Generate unique ID
  generateId() {
    return `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  // Clear all notifications
  clearAll() {
    this.notifications.clear();
    this.queue = [];

    // Remove all in-page notifications
    document.querySelectorAll('.in-page-notification').forEach((el) => {
      el.remove();
    });
  }
}

// Usage
const notifications = new SmartNotificationManager();

// Show notification based on visibility
notifications.show('New Message', {
  body: 'You have a new message from John',
  icon: '/avatar-john.png',
  onClick: () => {
    window.location.href = '/messages';
  },
});

// High priority notification (always shows)
notifications.show('Security Alert', {
  body: 'Unusual login attempt detected',
  priority: 'high',
  requireInteraction: true,
  sound: '/sounds/alert.mp3',
});

// Configure notification behavior
notifications.settings.queueWhenHidden = true;
notifications.settings.maxQueued = 5;

Best Practices

  1. Always provide fallbacks

    const isHidden =
      document.hidden !== undefined
        ? document.hidden
        : document.webkitHidden || document.mozHidden || false;
    
  2. Pause expensive operations when hidden

    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        pauseAnimations();
        stopVideoPlayback();
      }
    });
    
  3. Reduce resource usage in background

    const interval = document.hidden ? 60000 : 5000;
    setPollingInterval(interval);
    
  4. Save user progress

    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        saveUserProgress();
      }
    });
    

Conclusion

The Page Visibility API is essential for modern web applications:

  • Performance optimization by pausing unnecessary operations
  • Battery conservation on mobile devices
  • Better analytics with accurate engagement tracking
  • Improved UX with smart notifications
  • Resource management for background tabs
  • Bandwidth savings with throttled requests

Key takeaways:

  • Always check for vendor prefixes
  • Pause media and animations when hidden
  • Throttle network requests appropriately
  • Track user engagement accurately
  • Respect system resources
  • Test across different browsers

Build efficient, user-friendly applications that adapt to visibility changes!