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.
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">×</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
-
Always provide fallbacks
const isHidden = document.hidden !== undefined ? document.hidden : document.webkitHidden || document.mozHidden || false;
-
Pause expensive operations when hidden
document.addEventListener('visibilitychange', () => { if (document.hidden) { pauseAnimations(); stopVideoPlayback(); } });
-
Reduce resource usage in background
const interval = document.hidden ? 60000 : 5000; setPollingInterval(interval);
-
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!