Managing user attention in today’s multi-tab browsing environment presents unique challenges for web developers. When users switch between tabs, scroll through social media, or multitask across applications, your carefully crafted web experience can lose momentum, waste resources, or miss critical engagement opportunities.
JavaScript’s Page Visibility API provides powerful tools to detect when users switch tabs, allowing you to pause videos, save user progress, manage real-time connections, and create more responsive applications. Whether you’re building a video streaming platform that needs to pause playback when users switch tabs, an e-learning system that tracks engagement, or a financial application requiring enhanced security measures, understanding tab visibility detection is essential for modern web development.
This comprehensive guide will teach you practical techniques for implementing tab change detection, from basic visibility state monitoring to advanced security controls and performance optimizations that create seamless user experiences while respecting user privacy and browser limitations.
Why Tracking Browser Tab Changes Matters in Web Development
Modern web users operate in a multi-tab, multi-application environment where attention spans are fragmented and resource management becomes critical. Understanding when your application has user focus enables intelligent behavior that improves performance, enhances security, and creates more intuitive user experiences.
Performance Benefits:
- Resource conservation: Pause expensive operations like animations, video playback, or data fetching when tabs are inactive
- Battery optimization: Reduce CPU and GPU usage on mobile devices by limiting background processing
- Bandwidth management: Control real-time data synchronization based on user engagement
User Experience Improvements:
- Seamless media playback: Automatically pause and resume content based on user attention
- Progress preservation: Save user input and form data before potential tab closure
- Contextual notifications: Display relevant messages when users return to your application
Security and Compliance:
- Session management: Implement automatic logout for sensitive applications during prolonged inactivity
- Data protection: Hide or mask sensitive information when tabs lose focus
- Audit trails: Track user engagement for compliance and analytics purposes
Real-World Use Cases: From User Engagement to Security
Media and Entertainment Platforms: Netflix, YouTube, and Spotify use tab visibility detection to pause playback when users switch tabs, preserving user context and reducing unnecessary bandwidth consumption. This creates a more intuitive experience where content doesn’t play in background tabs.
E-Learning and Training Systems: Educational platforms like Coursera and Khan Academy monitor tab visibility to ensure students remain engaged during lessons, pausing video content and tracking attention for completion certificates and progress analytics.
Financial and Banking Applications: Online banking systems implement automatic logout mechanisms when users switch tabs for extended periods, protecting sensitive financial data from unauthorized access in shared computing environments.
Real-Time Collaboration Tools: Applications like Google Docs, Slack, and Zoom adjust their real-time synchronization behavior based on tab visibility, reducing server load while maintaining responsiveness for active users.
Gaming and Interactive Experiences: Browser-based games pause gameplay mechanics, stop timer functions, and reduce resource consumption when users switch tabs, preventing unfair advantages and conserving device resources.
Understanding the Page Visibility API
What Is the Page Visibility API and How It Works
The Page Visibility API provides a standardized way to determine when a webpage is visible or hidden from the user’s perspective. Unlike traditional focus and blur events that only detect when the entire browser window gains or loses focus, the Page Visibility API specifically tracks tab-level visibility states.
Core API Components:
document.hidden
Property: A boolean value indicating whether the page is currently hidden from view:
true
: Tab is not visible (user switched to another tab, minimized browser, or locked screen)false
: Tab is currently visible and has user attention
document.visibilityState
Property: Provides more detailed information about the page’s visibility state:
"visible"
: Page is the active tab in a non-minimized browser window"hidden"
: Page is not visible (tab is inactive, browser minimized, or screen locked)"prerender"
: Page is being prerendered and not visible to user (rarely used)
visibilitychange
Event: Fires whenever the page’s visibility state changes, allowing you to respond immediately to tab switches.
Supported Browsers and Compatibility Notes
The Page Visibility API enjoys excellent browser support across modern browsers:
Browser | Version | Notes |
Chrome | 14+ | Full support since 2011 |
Firefox | 18+ | Complete implementation |
Safari | 7+ | Full support on desktop and mobile |
Edge | 12+ | Native support in all versions |
Internet Explorer | 10+ | Limited support with vendor prefixes |
Compatibility Considerations:
// Feature detection with fallback
function hasPageVisibilitySupport() {
return typeof document.hidden !== 'undefined' ||
typeof document.webkitHidden !== 'undefined' ||
typeof document.mozHidden !== 'undefined' ||
typeof document.msHidden !== 'undefined';
}
// Cross-browser property access
function getVisibilityProps() {
if (typeof document.hidden !== 'undefined') {
return {
hidden: 'hidden',
visibilityState: 'visibilityState',
visibilityChange: 'visibilitychange'
};
} else if (typeof document.webkitHidden !== 'undefined') {
return {
hidden: 'webkitHidden',
visibilityState: 'webkitVisibilityState',
visibilityChange: 'webkitvisibilitychange'
};
} else if (typeof document.mozHidden !== 'undefined') {
return {
hidden: 'mozHidden',
visibilityState: 'mozVisibilityState',
visibilityChange: 'mozvisibilitychange'
};
} else if (typeof document.msHidden !== 'undefined') {
return {
hidden: 'msHidden',
visibilityState: 'msVisibilityState',
visibilityChange: 'msvisibilitychange'
};
}
return null;
}
Detecting When a Tab Is Hidden or Visible
Using document.hidden and visibilitychange Effectively
The most straightforward approach to tab visibility detection combines the document.hidden
property with the visibilitychange
event listener:
// Basic tab visibility detection
function initializeTabVisibilityDetection() {
// Check initial state
console.log('Page initially hidden:', document.hidden);
console.log('Initial visibility state:', document.visibilityState);
// Listen for visibility changes
document.addEventListener('visibilitychange', handleVisibilityChange);
}
function handleVisibilityChange() {
if (document.hidden) {
onTabHidden();
} else {
onTabVisible();
}
}
function onTabHidden() {
console.log('Tab is now hidden');
// Implement tab hidden logic
pauseActivities();
trackTabLeave();
}
function onTabVisible() {
console.log('Tab is now visible');
// Implement tab visible logic
resumeActivities();
trackTabReturn();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initializeTabVisibilityDetection);
Simple Code Example to Detect Tab Visibility State Changes
Complete Tab Detection System:
class TabVisibilityManager {
constructor() {
this.isHidden = document.hidden;
this.visibilityProps = this.getVisibilityProperties();
this.callbacks = {
hidden: [],
visible: []
};
this.timeHidden = 0;
this.timeVisible = 0;
this.lastStateChange = Date.now();
this.init();
}
getVisibilityProperties() {
const props = {
hidden: 'hidden',
visibilityState: 'visibilityState',
visibilityChange: 'visibilitychange'
};
// Handle vendor prefixes for older browsers
if (typeof document.hidden === 'undefined') {
const prefixes = ['webkit', 'moz', 'ms'];
for (const prefix of prefixes) {
const hiddenProp = `${prefix}Hidden`;
if (typeof document[hiddenProp] !== 'undefined') {
props.hidden = hiddenProp;
props.visibilityState = `${prefix}VisibilityState`;
props.visibilityChange = `${prefix}visibilitychange`;
break;
}
}
}
return props;
}
init() {
if (!this.visibilityProps) {
console.warn('Page Visibility API not supported');
return;
}
document.addEventListener(
this.visibilityProps.visibilityChange,
this.handleVisibilityChange.bind(this)
);
// Track initial state
this.lastStateChange = Date.now();
}
handleVisibilityChange() {
const now = Date.now();
const timeDelta = now - this.lastStateChange;
// Update time tracking
if (this.isHidden) {
this.timeHidden += timeDelta;
} else {
this.timeVisible += timeDelta;
}
// Update current state
this.isHidden = document[this.visibilityProps.hidden];
this.lastStateChange = now;
// Fire appropriate callbacks
const eventType = this.isHidden ? 'hidden' : 'visible';
this.fireCallbacks(eventType, {
isHidden: this.isHidden,
visibilityState: document[this.visibilityProps.visibilityState],
timeInPreviousState: timeDelta,
totalTimeHidden: this.timeHidden,
totalTimeVisible: this.timeVisible,
timestamp: now
});
}
onHidden(callback) {
this.callbacks.hidden.push(callback);
return this; // Allow chaining
}
onVisible(callback) {
this.callbacks.visible.push(callback);
return this; // Allow chaining
}
fireCallbacks(eventType, data) {
this.callbacks[eventType].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Error in visibility callback:', error);
}
});
}
// Utility methods
getCurrentState() {
return {
isHidden: this.isHidden,
visibilityState: document[this.visibilityProps.visibilityState],
timeHidden: this.timeHidden,
timeVisible: this.timeVisible
};
}
getTimeInCurrentState() {
return Date.now() - this.lastStateChange;
}
}
// Usage example
const tabManager = new TabVisibilityManager();
tabManager
.onHidden((data) => {
console.log('Tab hidden after', data.timeInPreviousState, 'ms of visibility');
// Pause video, save draft, reduce polling frequency
})
.onVisible((data) => {
console.log('Tab visible after', data.timeInPreviousState, 'ms hidden');
// Resume video, refresh data, restore normal operations
});
Enhanced Detection with Debouncing:
class DebouncedTabVisibilityManager extends TabVisibilityManager {
constructor(debounceDelay = 100) {
super();
this.debounceDelay = debounceDelay;
this.debounceTimer = null;
}
handleVisibilityChange() {
// Clear existing timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Debounce rapid changes
this.debounceTimer = setTimeout(() => {
super.handleVisibilityChange();
}, this.debounceDelay);
}
// Immediate state check without debouncing
getImmediateState() {
return document[this.visibilityProps.hidden];
}
}
Enhancing User Engagement with Visibility Detection
Pausing Media or Animations When User Leaves the Tab
Media content should respond intelligently to user attention to create better experiences and conserve resources:
class MediaVisibilityController {
constructor() {
this.mediaElements = new Set();
this.animationIds = new Set();
this.intervals = new Set();
this.tabManager = new TabVisibilityManager();
this.init();
}
init() {
this.tabManager
.onHidden(() => this.pauseAllMedia())
.onVisible(() => this.resumeAllMedia());
// Auto-detect media elements
this.detectMediaElements();
// Watch for dynamically added media
this.setupMediaObserver();
}
detectMediaElements() {
// Find all video and audio elements
const mediaElements = document.querySelectorAll('video, audio');
mediaElements.forEach(element => this.registerMediaElement(element));
}
setupMediaObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const mediaElements = node.querySelectorAll ?
node.querySelectorAll('video, audio') :
(node.matches && node.matches('video, audio') ? [node] : []);
mediaElements.forEach(element => this.registerMediaElement(element));
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
registerMediaElement(element) {
if (!this.mediaElements.has(element)) {
this.mediaElements.add(element);
// Store original autoplay state
element.dataset.originalAutoplay = element.autoplay;
// Add play/pause tracking
element.addEventListener('play', () => {
element.dataset.wasPlayingBeforeHidden = 'true';
});
element.addEventListener('pause', () => {
element.dataset.wasPlayingBeforeHidden = 'false';
});
}
}
pauseAllMedia() {
this.mediaElements.forEach(element => {
if (!element.paused && !element.ended) {
element.dataset.wasPlayingBeforeHidden = 'true';
element.pause();
console.log('Paused media element:', element.src || element.currentSrc);
}
});
this.pauseAnimations();
this.pauseIntervals();
}
resumeAllMedia() {
this.mediaElements.forEach(element => {
if (element.dataset.wasPlayingBeforeHidden === 'true') {
element.play().catch(error => {
console.warn('Could not resume media playback:', error);
});
console.log('Resumed media element:', element.src || element.currentSrc);
}
});
this.resumeAnimations();
this.resumeIntervals();
}
// Animation control methods
registerAnimation(animationId) {
this.animationIds.add(animationId);
}
registerInterval(intervalId) {
this.intervals.add(intervalId);
}
pauseAnimations() {
// Cancel all registered animation frames
this.animationIds.forEach(id => {
cancelAnimationFrame(id);
});
}
resumeAnimations() {
// Emit custom event for animations to restart
document.dispatchEvent(new CustomEvent('resumeAnimations'));
}
pauseIntervals() {
// Store interval data and clear them
this.intervals.forEach(intervalId => {
clearInterval(intervalId);
});
}
resumeIntervals() {
// Emit custom event for intervals to restart
document.dispatchEvent(new CustomEvent('resumeIntervals'));
}
}
// Usage with existing media elements
const mediaController = new MediaVisibilityController();
// Register custom animations
function startCustomAnimation() {
function animate() {
// Animation logic here
const animationId = requestAnimationFrame(animate);
mediaController.registerAnimation(animationId);
}
animate();
}
// Listen for resume events
document.addEventListener('resumeAnimations', () => {
startCustomAnimation();
});
Sending Reminder Notifications When the User Returns
Create engaging re-engagement experiences when users return to your tab:
class TabReturnNotificationSystem {
constructor() {
this.tabManager = new TabVisibilityManager();
this.notifications = [];
this.returnCallbacks = [];
this.hiddenStartTime = null;
this.init();
}
init() {
this.tabManager
.onHidden((data) => {
this.hiddenStartTime = data.timestamp;
this.scheduleNotifications();
})
.onVisible((data) => {
this.handleTabReturn(data);
this.clearScheduledNotifications();
});
}
scheduleNotifications() {
// Clear any existing notifications
this.clearScheduledNotifications();
// Schedule different notifications based on time away
const schedules = [
{ delay: 30000, message: "Come back! You have unsaved changes." },
{ delay: 300000, message: "Still there? Your session will expire soon." },
{ delay: 900000, message: "We miss you! Click to return to your work." }
];
schedules.forEach(({ delay, message }) => {
const timeoutId = setTimeout(() => {
this.showBrowserNotification(message);
}, delay);
this.notifications.push(timeoutId);
});
}
clearScheduledNotifications() {
this.notifications.forEach(timeoutId => clearTimeout(timeoutId));
this.notifications = [];
}
async showBrowserNotification(message) {
// Request permission if not granted
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
if (Notification.permission === 'granted') {
const notification = new Notification('Return to Application', {
body: message,
icon: '/favicon.ico',
tag: 'tab-return-reminder',
requireInteraction: true
});
notification.onclick = () => {
window.focus();
notification.close();
};
// Auto-close after 10 seconds
setTimeout(() => notification.close(), 10000);
}
}
handleTabReturn(data) {
const timeAway = data.timeInPreviousState;
// Show different messages based on time away
if (timeAway > 30000) { // 30 seconds
this.showWelcomeBackMessage(timeAway);
}
// Fire custom return callbacks
this.returnCallbacks.forEach(callback => {
try {
callback(timeAway, data);
} catch (error) {
console.error('Error in return callback:', error);
}
});
}
showWelcomeBackMessage(timeAway) {
const minutes = Math.floor(timeAway / 60000);
const seconds = Math.floor((timeAway % 60000) / 1000);
let message;
if (minutes > 0) {
message = `Welcome back! You were away for ${minutes} minute${minutes > 1 ? 's' : ''}.`;
} else {
message = `Welcome back! You were away for ${seconds} second${seconds > 1 ? 's' : ''}.`;
}
this.createWelcomeBackBanner(message);
}
createWelcomeBackBanner(message) {
// Remove any existing banner
const existingBanner = document.getElementById('welcome-back-banner');
if (existingBanner) {
existingBanner.remove();
}
// Create new banner
const banner = document.createElement('div');
banner.id = 'welcome-back-banner';
banner.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(90deg, #4CAF50, #45a049);
color: white;
padding: 12px 20px;
text-align: center;
z-index: 10000;
font-family: Arial, sans-serif;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transform: translateY(-100%);
transition: transform 0.3s ease-in-out;
`;
banner.innerHTML = `
<span>${message}</span>
<button onclick="this.parentElement.remove()" style="
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 4px 8px;
margin-left: 15px;
border-radius: 3px;
cursor: pointer;
">×</button>
`;
document.body.appendChild(banner);
// Animate in
setTimeout(() => {
banner.style.transform = 'translateY(0)';
}, 100);
// Auto-remove after 5 seconds
setTimeout(() => {
if (banner.parentElement) {
banner.style.transform = 'translateY(-100%)';
setTimeout(() => banner.remove(), 300);
}
}, 5000);
}
onReturn(callback) {
this.returnCallbacks.push(callback);
return this;
}
}
// Usage example
const notificationSystem = new TabReturnNotificationSystem();
notificationSystem.onReturn((timeAway, data) => {
console.log(`User returned after ${timeAway}ms`);
// Refresh data if user was away for more than 5 minutes
if (timeAway > 300000) {
refreshApplicationData();
}
});
Tracking Active and Inactive Time for Analytics
Implement comprehensive time tracking for user engagement analytics:
class UserEngagementTracker {
constructor() {
this.tabManager = new TabVisibilityManager();
this.sessionData = {
sessionStart: Date.now(),
totalActiveTime: 0,
totalInactiveTime: 0,
tabSwitches: 0,
engagementEvents: [],
currentSessionVisible: !document.hidden
};
this.heartbeatInterval = null;
this.analyticsEndpoint = '/api/analytics/engagement';
this.init();
}
init() {
this.tabManager
.onHidden((data) => {
this.recordTabSwitch('hidden', data);
this.pauseHeartbeat();
})
.onVisible((data) => {
this.recordTabSwitch('visible', data);
this.startHeartbeat();
});
// Start heartbeat if initially visible
if (!document.hidden) {
this.startHeartbeat();
}
// Track session end
window.addEventListener('beforeunload', () => {
this.endSession();
});
// Send periodic updates
setInterval(() => this.sendAnalytics(), 60000); // Every minute
}
recordTabSwitch(state, data) {
this.sessionData.tabSwitches++;
const event = {
type: 'tab_switch',
state: state,
timestamp: data.timestamp,
timeInPreviousState: data.timeInPreviousState,
totalActiveTime: data.totalTimeVisible,
totalInactiveTime: data.totalTimeHidden
};
this.sessionData.engagementEvents.push(event);
this.sessionData.totalActiveTime = data.totalTimeVisible;
this.sessionData.totalInactiveTime = data.totalTimeHidden;
console.log(`Tab ${state}:`, {
timeInPreviousState: data.timeInPreviousState,
totalActiveTime: this.sessionData.totalActiveTime,
totalInactiveTime: this.sessionData.totalInactiveTime
});
}
startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(() => {
this.recordHeartbeat();
}, 30000); // Every 30 seconds
}
pauseHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
recordHeartbeat() {
const event = {
type: 'heartbeat',
timestamp: Date.now(),
url: window.location.href,
scrollPosition: window.pageYOffset,
activeElement: document.activeElement.tagName
};
this.sessionData.engagementEvents.push(event);
}
// Track specific user interactions
trackInteraction(type, details = {}) {
const event = {
type: 'interaction',
subtype: type,
timestamp: Date.now(),
details: details,
isTabVisible: !document.hidden
};
this.sessionData.engagementEvents.push(event);
}
getEngagementMetrics() {
const currentTime = Date.now();
const sessionDuration = currentTime - this.sessionData.sessionStart;
const tabData = this.tabManager.getCurrentState();
return {
sessionDuration: sessionDuration,
activeTime: tabData.timeVisible,
inactiveTime: tabData.timeHidden,
engagementRate: (tabData.timeVisible / sessionDuration) * 100,
tabSwitches: this.sessionData.tabSwitches,
eventsCount: this.sessionData.engagementEvents.length,
averageActiveSessionLength: this.calculateAverageActiveSession(),
interactionDensity: this.calculateInteractionDensity()
};
}
calculateAverageActiveSession() {
const activeSessions = this.sessionData.engagementEvents
.filter(event => event.type === 'tab_switch' && event.state === 'hidden')
.map(event => event.timeInPreviousState);
if (activeSessions.length === 0) return 0;
const total = activeSessions.reduce((sum, duration) => sum + duration, 0);
return total / activeSessions.length;
}
calculateInteractionDensity() {
const interactions = this.sessionData.engagementEvents
.filter(event => event.type === 'interaction');
const activeTime = this.tabManager.getCurrentState().timeVisible;
return activeTime > 0 ? (interactions.length / activeTime) * 60000 : 0; // Per minute
}
async sendAnalytics() {
const metrics = this.getEngagementMetrics();
try {
await fetch(this.analyticsEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: this.generateSessionId(),
metrics: metrics,
events: this.sessionData.engagementEvents.slice(-10) // Last 10 events
})
});
// Clear old events to prevent memory issues
if (this.sessionData.engagementEvents.length > 100) {
this.sessionData.engagementEvents =
this.sessionData.engagementEvents.slice(-50);
}
} catch (error) {
console.error('Failed to send analytics:', error);
}
}
endSession() {
const finalMetrics = this.getEngagementMetrics();
// Use sendBeacon for reliable data transmission on page unload
if (navigator.sendBeacon) {
navigator.sendBeacon(
this.analyticsEndpoint,
JSON.stringify({
sessionId: this.generateSessionId(),
sessionEnd: true,
finalMetrics: finalMetrics,
events: this.sessionData.engagementEvents
})
);
}
}
generateSessionId() {
if (!this.sessionId) {
this.sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return this.sessionId;
}
}
// Usage with interaction tracking
const engagementTracker = new UserEngagementTracker();
// Track various user interactions
document.addEventListener('click', (e) => {
engagementTracker.trackInteraction('click', {
element: e.target.tagName,
className: e.target.className,
coordinates: { x: e.clientX, y: e.clientY }
});
});
document.addEventListener('scroll', () => {
engagementTracker.trackInteraction('scroll', {
scrollPosition: window.pageYOffset,
scrollPercentage: (window.pageYOffset / (document.body.scrollHeight - window.innerHeight)) * 100
});
});
// Track form interactions
document.addEventListener('input', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
engagementTracker.trackInteraction('form_input', {
fieldType: e.target.type,
fieldName: e.target.name,
valueLength: e.target.value.length
});
}
});
Security and Session Control Based on Tab Visibility
Auto-Logout Users After Inactivity
Implement secure session management that respects user behavior while protecting sensitive data:
class SecurityVisibilityManager {
constructor(options = {}) {
this.options = {
inactivityTimeout: options.inactivityTimeout || 900000, // 15 minutes
warningTime: options.warningTime || 120000, // 2 minutes before logout
sensitiveDataTimeout: options.sensitiveDataTimeout || 300000, // 5 minutes
maxHiddenTime: options.maxHiddenTime || 1800000, // 30 minutes
autoSaveInterval: options.autoSaveInterval || 30000, // 30 seconds
...options
};
this.tabManager = new TabVisibilityManager();
this.lastActivity = Date.now();
this.inactivityTimer = null;
this.warningTimer = null;
this.hiddenTimer = null;
this.autoSaveTimer = null;
this.isWarningShown = false;
this.init();
}
init() {
// Track tab visibility changes
this.tabManager
.onHidden((data) => {
this.handleTabHidden(data);
})
.onVisible((data) => {
this.handleTabVisible(data);
});
// Track user activity
this.setupActivityTracking();
// Start initial timers
this.resetInactivityTimer();
this.startAutoSave();
}
setupActivityTracking() {
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
activityEvents.forEach(event => {
document.addEventListener(event, () => {
this.recordActivity();
}, { passive: true });
});
// Throttle activity recording
this.recordActivity = this.throttle(this.recordActivity.bind(this), 1000);
}
recordActivity() {
this.lastActivity = Date.now();
this.resetInactivityTimer();
this.hideWarning();
}
handleTabHidden(data) {
console.log('Tab hidden - starting hidden timer');
// Start timer for maximum hidden time
this.hiddenTimer = setTimeout(() => {
this.forceLogout('Maximum hidden time exceeded');
}, this.options.maxHiddenTime);
// Save current state
this.autoSaveCurrentState();
// Mask sensitive data
this.maskSensitiveData();
}
handleTabVisible(data) {
console.log('Tab visible - clearing hidden timer');
// Clear hidden timer
if (this.hiddenTimer) {
clearTimeout(this.hiddenTimer);
this.hiddenTimer = null;
}
// Unmask sensitive data
this.unmaskSensitiveData();
// Check if too much time has passed
const timeHidden = data.timeInPreviousState;
if (timeHidden > this.options.sensitiveDataTimeout) {
this.requireReauthentication();
} else {
// Reset activity timer
this.recordActivity();
}
}
resetInactivityTimer() {
// Clear existing timers
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
if (this.warningTimer) clearTimeout(this.warningTimer);
this.hideWarning();
// Set warning timer
this.warningTimer = setTimeout(() => {
this.showInactivityWarning();
}, this.options.inactivityTimeout - this.options.warningTime);
// Set logout timer
this.inactivityTimer = setTimeout(() => {
this.forceLogout('Session timeout due to inactivity');
}, this.options.inactivityTimeout);
}
showInactivityWarning() {
if (this.isWarningShown) return;
this.isWarningShown = true;
const remainingTime = Math.ceil(this.options.warningTime / 1000);
this.createWarningModal(remainingTime);
}
createWarningModal(initialSeconds) {
// Remove existing modal
const existingModal = document.getElementById('inactivity-warning-modal');
if (existingModal) {
existingModal.remove();
}
// Create modal
const modal = document.createElement('div');
modal.id = 'inactivity-warning-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 400px;
width: 90%;
`;
modalContent.innerHTML = `
<h3 style="margin: 0 0 15px 0; color: #d32f2f;">Session Warning</h3>
<p style="margin: 0 0 20px 0; color: #666;">
Your session will expire in <span id="countdown">${initialSeconds}</span> seconds due to inactivity.
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button id="extend-session" style="
background: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Stay Logged In</button>
<button id="logout-now" style="
background: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Logout Now</button>
</div>
`;
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Countdown timer
let seconds = initialSeconds;
const countdown = document.getElementById('countdown');
const countdownInterval = setInterval(() => {
seconds--;
countdown.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownInterval);
this.forceLogout('Session timeout');
}
}, 1000);
// Event handlers
document.getElementById('extend-session').onclick = () => {
clearInterval(countdownInterval);
this.extendSession();
};
document.getElementById('logout-now').onclick = () => {
clearInterval(countdownInterval);
this.forceLogout('User requested logout');
};
// Store countdown interval for cleanup
modal.dataset.countdownInterval = countdownInterval;
}
hideWarning() {
if (!this.isWarningShown) return;
this.isWarningShown = false;
const modal = document.getElementById('inactivity-warning-modal');
if (modal) {
// Clear countdown interval
const countdownInterval = modal.dataset.countdownInterval;
if (countdownInterval) {
clearInterval(countdownInterval);
}
modal.remove();
}
}
extendSession() {
this.hideWarning();
this.recordActivity();
console.log('Session extended by user');
}
maskSensitiveData() {
const sensitiveSelectors = [
'input[type="password"]',
'input[type="email"]',
'.sensitive-data',
'.account-number',
'.credit-card',
'.ssn'
];
sensitiveSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(element => {
if (!element.dataset.originalValue) {
element.dataset.originalValue = element.value || element.textContent;
}
if (element.tagName === 'INPUT') {
element.value = '••••••••••';
element.type = 'password';
} else {
element.textContent = '••••••••••';
}
element.classList.add('data-masked');
});
});
console.log('Sensitive data masked');
}
unmaskSensitiveData() {
document.querySelectorAll('.data-masked').forEach(element => {
if (element.dataset.originalValue) {
if (element.tagName === 'INPUT') {
element.value = element.dataset.originalValue;
// Restore original input type (if it wasn't password)
if (element.dataset.originalType) {
element.type = element.dataset.originalType;
}
} else {
element.textContent = element.dataset.originalValue;
}
delete element.dataset.originalValue;
element.classList.remove('data-masked');
}
});
console.log('Sensitive data unmasked');
}
requireReauthentication() {
console.log('Requiring reauthentication due to extended inactivity');
// Create reauthentication modal
this.createReauthModal();
}
createReauthModal() {
const modal = document.createElement('div');
modal.id = 'reauth-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
`;
modal.innerHTML = `
<div style="
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 400px;
width: 90%;
">
<h3 style="margin: 0 0 15px 0; color: #ff9800;">Reauthentication Required</h3>
<p style="margin: 0 0 20px 0; color: #666;">
For security reasons, please enter your password to continue.
</p>
<form id="reauth-form">
<input type="password" id="reauth-password" placeholder="Enter your password" style="
width: 100%;
padding: 10px;
margin: 0 0 15px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
">
<div style="display: flex; gap: 10px; justify-content: center;">
<button type="submit" style="
background: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Authenticate</button>
<button type="button" id="reauth-logout" style="
background: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Logout</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// Event handlers
document.getElementById('reauth-form').onsubmit = (e) => {
e.preventDefault();
this.verifyReauthentication(document.getElementById('reauth-password').value);
};
document.getElementById('reauth-logout').onclick = () => {
this.forceLogout('User chose to logout during reauthentication');
};
// Focus password field
document.getElementById('reauth-password').focus();
}
async verifyReauthentication(password) {
try {
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password })
});
if (response.ok) {
document.getElementById('reauth-modal').remove();
this.recordActivity();
console.log('Reauthentication successful');
} else {
this.showReauthError('Invalid password. Please try again.');
}
} catch (error) {
this.showReauthError('Authentication failed. Please try again.');
}
}
showReauthError(message) {
const existingError = document.getElementById('reauth-error');
if (existingError) {
existingError.remove();
}
const error = document.createElement('div');
error.id = 'reauth-error';
error.style.cssText = `
color: #f44336;
font-size: 12px;
margin: 10px 0;
`;
error.textContent = message;
const form = document.getElementById('reauth-form');
form.insertBefore(error, form.firstChild);
// Clear password field
document.getElementById('reauth-password').value = '';
document.getElementById('reauth-password').focus();
}
startAutoSave() {
this.autoSaveTimer = setInterval(() => {
this.autoSaveCurrentState();
}, this.options.autoSaveInterval);
}
autoSaveCurrentState() {
// Collect form data
const formData = this.collectFormData();
if (Object.keys(formData).length > 0) {
localStorage.setItem('autoSave_' + Date.now(), JSON.stringify({
data: formData,
timestamp: Date.now(),
url: window.location.href
}));
// Keep only last 5 auto-saves
this.cleanupAutoSaves();
console.log('Auto-saved form data');
}
}
collectFormData() {
const formData = {};
const forms = document.querySelectorAll('form');
forms.forEach((form, formIndex) => {
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach((input, inputIndex) => {
if (input.type !== 'password' && input.value.trim()) {
const key = input.name || input.id || `form${formIndex}_input${inputIndex}`;
formData[key] = input.value;
}
});
});
return formData;
}
cleanupAutoSaves() {
const keys = Object.keys(localStorage)
.filter(key => key.startsWith('autoSave_'))
.sort()
.reverse();
// Remove old auto-saves (keep only 5)
keys.slice(5).forEach(key => {
localStorage.removeItem(key);
});
}
forceLogout(reason) {
console.log('Force logout:', reason);
// Clear all timers
[this.inactivityTimer, this.warningTimer, this.hiddenTimer, this.autoSaveTimer]
.forEach(timer => timer && clearTimeout(timer));
// Save final state
this.autoSaveCurrentState();
// Clear sensitive data
this.clearSensitiveData();
// Redirect to logout
if (this.options.logoutCallback) {
this.options.logoutCallback(reason);
} else {
window.location.href = '/logout?reason=' + encodeURIComponent(reason);
}
}
clearSensitiveData() {
// Clear form data
document.querySelectorAll('input[type="password"]').forEach(input => {
input.value = '';
});
// Clear any application-specific sensitive data
if (this.options.clearDataCallback) {
this.options.clearDataCallback();
}
}
// Utility function for throttling
throttle(func, wait) {
let timeout;
let previous = 0;
return function(...args) {
const now = Date.now();
if (now - previous > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
func.apply(this, args);
}, wait - (now - previous));
}
};
}
// Public methods
getSessionInfo() {
return {
lastActivity: this.lastActivity,
timeSinceLastActivity: Date.now() - this.lastActivity,
isWarningShown: this.isWarningShown,
inactivityTimeout: this.options.inactivityTimeout
};
}
extendSessionManually(additionalTime = 900000) { // 15 minutes default
this.lastActivity = Date.now() + additionalTime;
this.resetInactivityTimer();
console.log('Session extended manually by', additionalTime / 1000, 'seconds');
}
}
// Usage example
const securityManager = new SecurityVisibilityManager({
inactivityTimeout: 1800000, // 30 minutes
warningTime: 300000, // 5 minutes warning
sensitiveDataTimeout: 600000, // 10 minutes for tab hidden
maxHiddenTime: 3600000, // 1 hour maximum hidden
logoutCallback: (reason) => {
console.log('Logout triggered:', reason);
// Custom logout logic here
fetch('/api/auth/logout', {
method: 'POST',
body: JSON.stringify({ reason })
}).then(() => {
window.location.href = '/login';
});
},
clearDataCallback: () => {
// Clear application-specific data
sessionStorage.clear();
// Clear any application state
}
});
Preventing Sensitive Data Exposure When Tab Is Hidden
Advanced Data Protection System:
class SensitiveDataProtector {
constructor() {
this.tabManager = new TabVisibilityManager();
this.protectionLevels = {
'low': { maskDelay: 300000, blurDelay: 0 }, // 5 minutes
'medium': { maskDelay: 60000, blurDelay: 0 }, // 1 minute
'high': { maskDelay: 5000, blurDelay: 0 }, // 5 seconds
'critical': { maskDelay: 0, blurDelay: 0 } // Immediate
};
this.protectedElements = new Map();
this.blurOverlay = null;
this.maskingTimer = null;
this.init();
}
init() {
this.tabManager
.onHidden(() => this.activateProtection())
.onVisible(() => this.deactivateProtection());
// Register sensitive elements automatically
this.autoRegisterSensitiveElements();
// Watch for new elements
this.setupElementObserver();
}
autoRegisterSensitiveElements() {
const sensitiveSelectors = {
'critical': [
'input[type="password"]',
'.ssn',
'.credit-card-number',
'.bank-account'
],
'high': [
'input[type="email"]',
'.phone-number',
'.address',
'.financial-data'
],
'medium': [
'.personal-info',
'.user-profile',
'.account-details'
],
'low': [
'.general-content',
'.non-critical'
]
};
Object.entries(sensitiveSelectors).forEach(([level, selectors]) => {
selectors.forEach(selector => {
document.querySelectorAll(selector).forEach(element => {
this.registerElement(element, level);
});
});
});
}
registerElement(element, protectionLevel = 'medium') {
if (!this.protectedElements.has(element)) {
this.protectedElements.set(element, {
level: protectionLevel,
originalValue: element.value || element.textContent,
originalType: element.type,
isProtected: false
});
}
}
activateProtection() {
// Apply immediate protection for critical elements
this.protectElementsByLevel('critical');
// Set timer for other levels
this.maskingTimer = setTimeout(() => {
this.protectElementsByLevel('high');
setTimeout(() => {
this.protectElementsByLevel('medium');
setTimeout(() => {
this.protectElementsByLevel('low');
}, 60000); // 1 minute more for low
}, 60000); // 1 minute more for medium
}, 5000); // 5 seconds for high
// Apply blur overlay for visual protection
this.createBlurOverlay();
}
protectElementsByLevel(level) {
this.protectedElements.forEach((data, element) => {
if (data.level === level && !data.isProtected) {
this.protectElement(element, data);
}
});
}
protectElement(element, data) {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.dataset.originalValue = element.value;
element.dataset.originalType = element.type;
element.value = '••••••••••';
element.type = 'password';
element.readOnly = true;
} else {
element.dataset.originalText = element.textContent;
element.textContent = '••••••••••';
}
element.classList.add('data-protected');
data.isProtected = true;
console.log(`Protected ${data.level} level element`);
}
createBlurOverlay() {
if (this.blurOverlay) return;
this.blurOverlay = document.createElement('div');
this.blurOverlay.id = 'security-blur-overlay';
this.blurOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(5px);
z-index: 999998;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease-in-out;
`;
document.body.appendChild(this.blurOverlay);
// Fade in
setTimeout(() => {
this.blurOverlay.style.opacity = '1';
}, 100);
}
deactivateProtection() {
// Clear protection timer
if (this.maskingTimer) {
clearTimeout(this.maskingTimer);
this.maskingTimer = null;
}
// Remove blur overlay
this.removeBlurOverlay();
// Restore all protected elements
this.protectedElements.forEach((data, element) => {
if (data.isProtected) {
this.restoreElement(element, data);
}
});
}
restoreElement(element, data) {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.value = element.dataset.originalValue || data.originalValue;
element.type = element.dataset.originalType || data.originalType;
element.readOnly = false;
} else {
element.textContent = element.dataset.originalText || data.originalValue;
}
element.classList.remove('data-protected');
data.isProtected = false;
// Clean up data attributes
delete element.dataset.originalValue;
delete element.dataset.originalType;
delete element.dataset.originalText;
}
removeBlurOverlay() {
if (this.blurOverlay) {
this.blurOverlay.style.opacity = '0';
setTimeout(() => {
if (this.blurOverlay && this.blurOverlay.parentElement) {
this.blurOverlay.remove();
}
this.blurOverlay = null;
}, 300);
}
}
setupElementObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node itself is sensitive
if (this.isSensitiveElement(node)) {
const level = this.determineSensitivityLevel(node);
this.registerElement(node, level);
}
// Check for sensitive child elements
const sensitiveChildren = node.querySelectorAll ?
node.querySelectorAll(this.getSensitiveSelectors().join(', ')) : [];
sensitiveChildren.forEach(child => {
const level = this.determineSensitivityLevel(child);
this.registerElement(child, level);
});
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
isSensitiveElement(element) {
const sensitiveSelectors = this.getSensitiveSelectors();
return sensitiveSelectors.some(selector => {
try {
return element.matches(selector);
} catch (e) {
return false;
}
});
}
getSensitiveSelectors() {
return [
'input[type="password"]',
'input[type="email"]',
'.ssn', '.credit-card-number', '.bank-account',
'.phone-number', '.address', '.financial-data',
'.personal-info', '.user-profile', '.account-details'
];
}
determineSensitivityLevel(element) {
const criticalSelectors = ['input[type="password"]', '.ssn', '.credit-card-number', '.bank-account'];
const highSelectors = ['input[type="email"]', '.phone-number', '.address', '.financial-data'];
const mediumSelectors = ['.personal-info', '.user-profile', '.account-details'];
if (criticalSelectors.some(selector => element.matches(selector))) {
return 'critical';
} else if (highSelectors.some(selector => element.matches(selector))) {
return 'high';
} else if (mediumSelectors.some(selector => element.matches(selector))) {
return 'medium';
}
return 'low';
}
// Public methods
setProtectionLevel(element, level) {
if (this.protectedElements.has(element)) {
this.protectedElements.get(element).level = level;
} else {
this.registerElement(element, level);
}
}
forceProtectAll() {
this.protectedElements.forEach((data, element) => {
if (!data.isProtected) {
this.protectElement(element, data);
}
});
this.createBlurOverlay();
}
forceRestoreAll() {
this.protectedElements.forEach((data, element) => {
if (data.isProtected) {
this.restoreElement(element, data);
}
});
this.removeBlurOverlay();
}
}
// Usage
const dataProtector = new SensitiveDataProtector();
// Register custom sensitive elements
document.querySelectorAll('.custom-sensitive').forEach(element => {
dataProtector.registerElement(element, 'high');
});
Combining Visibility Events with Session Timers
Integrated Session and Visibility Management:
class IntegratedSessionManager {
constructor(options = {}) {
this.options = {
sessionTimeout: options.sessionTimeout || 1800000, // 30 minutes
tabHiddenMultiplier: options.tabHiddenMultiplier || 0.5, // Count hidden time as half
warningThreshold: options.warningThreshold || 0.9, // Warn at 90% of timeout
extendOnActivity: options.extendOnActivity !== false,
saveStateInterval: options.saveStateInterval || 60000, // 1 minute
...options
};
this.tabManager = new TabVisibilityManager();
this.securityManager = new SecurityVisibilityManager(this.options);
this.sessionState = {
startTime: Date.now(),
effectiveTime: 0, // Time counting towards session limit
hiddenTime: 0,
visibleTime: 0,
lastActivity: Date.now(),
isActive: true
};
this.sessionTimer = null;
this.stateUpdateInterval = null;
this.init();
}
init() {
this.tabManager
.onHidden((data) => this.handleTabHidden(data))
.onVisible((data) => this.handleTabVisible(data));
this.startSessionTracking();
this.startStateUpdates();
// Track user activity
this.setupActivityTracking();
}
handleTabHidden(data) {
console.log('Tab hidden - adjusting session timer');
this.updateEffectiveTime();
// Slower session countdown when hidden
this.adjustSessionTimer(this.options.tabHiddenMultiplier);
}
handleTabVisible(data) {
console.log('Tab visible - restoring normal session timer');
this.updateEffectiveTime();
// Normal session countdown when visible
this.adjustSessionTimer(1.0);
// Check if session should be extended due to return
if (data.timeInPreviousState > 60000) { // If away for more than 1 minute
this.handleUserReturn(data.timeInPreviousState);
}
}
updateEffectiveTime() {
const now = Date.now();
const timeSinceLastUpdate = now - this.sessionState.lastActivity;
if (document.hidden) {
// Count hidden time with multiplier
this.sessionState.effectiveTime += timeSinceLastUpdate * this.options.tabHiddenMultiplier;
this.sessionState.hiddenTime += timeSinceLastUpdate;
} else {
// Count visible time normally
this.sessionState.effectiveTime += timeSinceLastUpdate;
this.sessionState.visibleTime += timeSinceLastUpdate;
}
this.sessionState.lastActivity = now;
}
adjustSessionTimer(multiplier) {
if (this.sessionTimer) {
clearTimeout(this.sessionTimer);
}
const remainingTime = this.getRemainingSessionTime();
const adjustedTime = remainingTime / multiplier;
this.sessionTimer = setTimeout(() => {
this.handleSessionExpiry();
}, adjustedTime);
console.log(`Session timer adjusted: ${adjustedTime}ms remaining (multiplier: ${multiplier})`);
}
startSessionTracking() {
this.sessionTimer = setTimeout(() => {
this.handleSessionExpiry();
}, this.options.sessionTimeout);
}
startStateUpdates() {
this.stateUpdateInterval = setInterval(() => {
this.updateSessionState();
this.checkWarningThreshold();
this.saveSessionState();
}, this.options.saveStateInterval);
}
updateSessionState() {
this.updateEffectiveTime();
const sessionProgress = this.getSessionProgress();
// Emit session update event
document.dispatchEvent(new CustomEvent('sessionUpdate', {
detail: {
progress: sessionProgress,
remainingTime: this.getRemainingSessionTime(),
effectiveTime: this.sessionState.effectiveTime,
isHidden: document.hidden
}
}));
}
checkWarningThreshold() {
const progress = this.getSessionProgress();
if (progress >= this.options.warningThreshold && !this.warningShown) {
this.showSessionWarning();
this.warningShown = true;
}
}
showSessionWarning() {
const remainingTime = this.getRemainingSessionTime();
const minutes = Math.ceil(remainingTime / 60000);
console.log(`Session warning: ${minutes} minutes remaining`);
// Create session warning notification
this.createSessionWarningBanner(minutes);
}
createSessionWarningBanner(minutes) {
const banner = document.createElement('div');
banner.id = 'session-warning-banner';
banner.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(90deg, #ff9800, #f57c00);
color: white;
padding: 10px 20px;
text-align: center;
z-index: 999999;
font-family: Arial, sans-serif;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
banner.innerHTML = `
<span>⚠️ Session expires in ${minutes} minute${minutes > 1 ? 's' : ''}. Activity will extend your session.</span>
<button onclick="this.parentElement.remove(); this.onclick=null;" style="
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 5px 10px;
margin-left: 15px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
">Dismiss</button>
`;
document.body.appendChild(banner);
// Auto-remove after 10 seconds if not dismissed
setTimeout(() => {
if (banner.parentElement) {
banner.remove();
}
}, 10000);
}
handleUserReturn(timeAway) {
// Extend session if user returns after brief absence
if (timeAway > 60000 && timeAway < 600000) { // 1-10 minutes
const extensionTime = Math.min(timeAway * 0.5, 300000); // Max 5 minutes extension
this.extendSession(extensionTime);
console.log(`Session extended by ${extensionTime / 1000}s due to user return`);
}
}
extendSession(additionalTime = 900000) { // 15 minutes default
this.sessionState.effectiveTime -= additionalTime;
this.warningShown = false;
// Remove any existing warning
const warning = document.getElementById('session-warning-banner');
if (warning) {
warning.remove();
}
// Restart timer with new time
this.adjustSessionTimer(document.hidden ? this.options.tabHiddenMultiplier : 1.0);
console.log(`Session extended by ${additionalTime / 1000} seconds`);
}
setupActivityTracking() {
const activityEvents = ['click', 'keydown', 'scroll', 'mousemove', 'touchstart'];
activityEvents.forEach(event => {
document.addEventListener(event, this.handleActivity.bind(this), { passive: true });
});
// Throttle activity handling
this.handleActivity = this.throttle(this.handleActivity, 5000); // Max once per 5 seconds
}
handleActivity() {
if (this.options.extendOnActivity) {
const timeSinceLastActivity = Date.now() - this.sessionState.lastActivity;
// Extend session on significant activity
if (timeSinceLastActivity > 300000) { // 5 minutes of inactivity
this.extendSession(300000); // Extend by 5 minutes
}
}
this.sessionState.lastActivity = Date.now();
}
handleSessionExpiry() {
console.log('Session expired');
this.saveSessionState();
// Create session expired modal
this.createSessionExpiredModal();
}
createSessionExpiredModal() {
const modal = document.createElement('div');
modal.id = 'session-expired-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000001;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
`;
modal.innerHTML = `
<div style="
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 450px;
width: 90%;
">
<h2 style="margin: 0 0 20px 0; color: #d32f2f;">Session Expired</h2>
<p style="margin: 0 0 20px 0; color: #666; line-height: 1.5;">
Your session has expired for security reasons. Please log in again to continue.
</p>
<div style="margin: 20px 0; padding: 15px; background: #f5f5f5; border-radius: 4px; font-size: 12px; color: #777;">
<strong>Session Summary:</strong><br>
Total time: ${this.formatTime(Date.now() - this.sessionState.startTime)}<br>
Active time: ${this.formatTime(this.sessionState.visibleTime)}<br>
Hidden time: ${this.formatTime(this.sessionState.hiddenTime)}
</div>
<div style="display: flex; gap: 10px; justify-content: center;">
<button onclick="window.location.href='/login'" style="
background: #4CAF50;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Login Again</button>
<button onclick="window.location.href='/'" style="
background: #757575;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
">Go Home</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Prevent interaction with page
document.body.style.overflow = 'hidden';
}
saveSessionState() {
const stateData = {
...this.sessionState,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
};
try {
localStorage.setItem('sessionState', JSON.stringify(stateData));
// Also send to server for analytics
this.sendSessionAnalytics(stateData);
} catch (error) {
console.error('Failed to save session state:', error);
}
}
async sendSessionAnalytics(stateData) {
try {
await fetch('/api/analytics/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(stateData),
keepalive: true // Ensure delivery even during page unload
});
} catch (error) {
console.error('Failed to send session analytics:', error);
}
}
// Utility methods
getRemainingSessionTime() {
return Math.max(0, this.options.sessionTimeout - this.sessionState.effectiveTime);
}
getSessionProgress() {
return Math.min(1, this.sessionState.effectiveTime / this.options.sessionTimeout);
}
formatTime(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
throttle(func, wait) {
let timeout;
let previous = 0;
return function(...args) {
const now = Date.now();
if (now - previous > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
func.apply(this, args);
}, wait - (now - previous));
}
};
}
// Public API
getSessionInfo() {
return {
...this.sessionState,
remainingTime: this.getRemainingSessionTime(),
progress: this.getSessionProgress(),
isHidden: document.hidden
};
}
forceExtendSession(time = 900000) {
this.extendSession(time);
}
forceExpireSession() {
this.handleSessionExpiry();
}
}
// Initialize integrated session management
const sessionManager = new IntegratedSessionManager({
sessionTimeout: 1800000, // 30 minutes
tabHiddenMultiplier: 0.3, // Hidden time counts as 30% of normal time
warningThreshold: 0.85, // Warn at 85% of session time
extendOnActivity: true,
saveStateInterval: 30000 // Save state every 30 seconds
});
// Listen for session updates
document.addEventListener('sessionUpdate', (event) => {
const { progress, remainingTime, isHidden } = event.detail;
// Update UI elements showing session status
const sessionIndicator = document.getElementById('session-indicator');
if (sessionIndicator) {
sessionIndicator.textContent = `Session: ${Math.ceil(remainingTime / 60000)}min remaining`;
sessionIndicator.style.opacity = isHidden ? '0.5' : '1';
}
});
// Manual session extension button
function extendCurrentSession() {
sessionManager.forceExtendSession(900000); // 15 minutes
alert('Session extended by 15 minutes');
}
Optimizing Performance and User Experience
Reducing Resource Consumption During Inactivity
Implement intelligent resource management based on tab visibility:
class ResourceOptimizationManager {
constructor() {
this.tabManager = new TabVisibilityManager();
this.optimizations = {
intervals: new Map(),
animations: new Set(),
connections: new Set(),
pollingTasks: new Map(),
mediaElements: new Set()
};
this.performanceMode = 'normal'; // 'normal', 'power-saver', 'aggressive'
this.inactivityTimer = null;
this.batteryInfo = null;
this.init();
}
async init() {
this.tabManager
.onHidden(() => this.activateOptimizations())
.onVisible(() => this.deactivateOptimizations());
// Get battery information if available
if ('getBattery' in navigator) {
try {
this.batteryInfo = await navigator.getBattery();
this.setupBatteryOptimizations();
} catch (error) {
console.log('Battery API not available');
}
}
// Auto-detect resource-intensive operations
this.detectResourceIntensiveOperations();
// Monitor performance
this.startPerformanceMonitoring();
}
setupBatteryOptimizations() {
if (!this.batteryInfo) return;
const updateOptimizationLevel = () => {
if (this.batteryInfo.level < 0.2 && !this.batteryInfo.charging) {
this.performanceMode = 'aggressive';
} else if (this.batteryInfo.level < 0.5 && !this.batteryInfo.charging) {
this.performanceMode = 'power-saver';
} else {
this.performanceMode = 'normal';
}
console.log(`Performance mode: ${this.performanceMode} (battery: ${Math.round(this.batteryInfo.level * 100)}%)`);
};
this.batteryInfo.addEventListener('chargingchange', updateOptimizationLevel);
this.batteryInfo.addEventListener('levelchange', updateOptimizationLevel);
updateOptimizationLevel();
}
activateOptimizations() {
console.log('Activating resource optimizations');
this.pauseIntervals();
this.pauseAnimations();
this.reduceNetworkActivity();
this.pauseMediaPlayback();
this.scheduleGarbageCollection();
// Set inactivity timer for deeper optimizations
this.inactivityTimer = setTimeout(() => {
this.activateDeepOptimizations();
}, 60000); // After 1 minute of inactivity
}
deactivateOptimizations() {
console.log('Deactivating resource optimizations');
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
this.resumeIntervals();
this.resumeAnimations();
this.restoreNetworkActivity();
this.resumeMediaPlayback();
this.deactivateDeepOptimizations();
}
pauseIntervals() {
// Store and pause all registered intervals
this.optimizations.intervals.forEach((data, intervalId) => {
clearInterval(intervalId);
data.paused = true;
});
// Find and pause common polling intervals
this.pausePollingOperations();
}
resumeIntervals() {
this.optimizations.intervals.forEach((data, intervalId) => {
if (data.paused) {
const newId = setInterval(data.callback, data.interval);
this.optimizations.intervals.delete(intervalId);
this.optimizations.intervals.set(newId, { ...data, paused: false });
}
});
}
pauseAnimations() {
// Cancel all tracked animation frames
this.optimizations.animations.forEach(animationId => {
cancelAnimationFrame(animationId);
});
// Pause CSS animations
document.documentElement.style.setProperty('--animation-play-state', 'paused');
// Dispatch event for custom animation handling
document.dispatchEvent(new CustomEvent('pauseAnimations'));
}
resumeAnimations() {
// Resume CSS animations
document.documentElement.style.setProperty('--animation-play-state', 'running');
// Dispatch event for custom animation handling
document.dispatchEvent(new CustomEvent('resumeAnimations'));
}
reduceNetworkActivity() {
// Reduce polling frequency for non-critical requests
this.optimizations.pollingTasks.forEach((task, taskId) => {
if (task.reducible) {
clearInterval(task.intervalId);
const reducedInterval = task.originalInterval * (
this.performanceMode === 'aggressive' ? 10 :
this.performanceMode === 'power-saver' ? 5 : 2
);
task.intervalId = setInterval(task.callback, reducedInterval);
console.log(`Reduced polling for ${taskId} from ${task.originalInterval}ms to ${reducedInterval}ms`);
}
});
// Pause WebSocket heartbeats if not critical
this.pauseWebSocketHeartbeats();
}
restoreNetworkActivity() {
// Restore original polling frequencies
this.optimizations.pollingTasks.forEach((task, taskId) => {
if (task.reducible && task.intervalId) {
clearInterval(task.intervalId);
task.intervalId = setInterval(task.callback, task.originalInterval);
console.log(`Restored polling for ${taskId} to ${task.originalInterval}ms`);
}
});
// Resume WebSocket heartbeats
this.resumeWebSocketHeartbeats();
}
pauseMediaPlayback() {
this.optimizations.mediaElements.forEach(element => {
if (!element.paused && !element.ended) {
element.dataset.wasPlayingBeforeOptimization = 'true';
element.pause();
}
});
}
resumeMediaPlayback() {
this.optimizations.mediaElements.forEach(element => {
if (element.dataset.wasPlayingBeforeOptimization === 'true') {
element.play().catch(error => {
console.log('Could not resume media playback:', error);
});
delete element.dataset.wasPlayingBeforeOptimization;
}
});
}
activateDeepOptimizations() {
console.log('Activating deep optimizations after extended inactivity');
// Reduce memory usage
this.performMemoryCleanup();
// Suspend heavy operations
this.suspendHeavyOperations();
// Reduce visual updates
this.reduceVisualUpdates();
}
deactivateDeepOptimizations() {
console.log('Deactivating deep optimizations');
// Resume heavy operations
this.resumeHeavyOperations();
// Restore visual updates
this.restoreVisualUpdates();
}
performMemoryCleanup() {
// Clear caches
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => {
if (name.includes('temp') || name.includes('cache')) {
caches.delete(name);
}
});
});
}
// Clear large data structures if possible
document.dispatchEvent(new CustomEvent('performMemoryCleanup'));
// Force garbage collection if available (Chrome DevTools)
if (window.gc && typeof window.gc === 'function') {
window.gc();
}
}
scheduleGarbageCollection() {
// Schedule periodic cleanup during inactivity
setTimeout(() => {
if (document.hidden) {
this.performMemoryCleanup();
}
}, 30000);
}
// Registration methods for applications to use
registerInterval(intervalId, callback, interval, options = {}) {
this.optimizations.intervals.set(intervalId, {
callback,
interval,
paused: false,
critical: options.critical || false,
reducible: options.reducible !== false
});
}
registerAnimation(animationId) {
this.optimizations.animations.add(animationId);
}
registerPollingTask(taskId, callback, interval, options = {}) {
this.optimizations.pollingTasks.set(taskId, {
callback,
originalInterval: interval,
intervalId: setInterval(callback, interval),
reducible: options.reducible !== false,
critical: options.critical || false
});
}
registerMediaElement(element) {
this.optimizations.mediaElements.add(element);
}
// Performance monitoring
startPerformanceMonitoring() {
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
if (entry.entryType === 'measure' && entry.duration > 16.67) { // Slower than 60fps
console.warn(`Slow operation detected: ${entry.name} took ${entry.duration}ms`);
}
});
});
observer.observe({ entryTypes: ['measure', 'navigation'] });
}
// Monitor memory usage
setInterval(() => {
if ('memory' in performance) {
const memory = performance.memory;
const usagePercent = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100;
if (usagePercent > 80) {
console.warn(`High memory usage: ${usagePercent.toFixed(1)}%`);
if (document.hidden) {
this.performMemoryCleanup();
}
}
}
}, 30000);
}
detectResourceIntensiveOperations() {
// Override common resource-intensive methods to track them
const originalSetInterval = window.setInterval;
const originalRequestAnimationFrame = window.requestAnimationFrame;
window.setInterval = (callback, delay, ...args) => {
const id = originalSetInterval.call(window, callback, delay, ...args);
// Auto-register intervals that run frequently
if (delay < 1000) {
this.registerInterval(id, callback, delay, { reducible: true });
}
return id;
};
window.requestAnimationFrame = (callback) => {
const id = originalRequestAnimationFrame.call(window, callback);
this.registerAnimation(id);
return id;
};
}
// Utility methods for specific optimizations
pausePollingOperations() {
// Look for common polling patterns
const potentialPolling = Array.from(this.optimizations.intervals.entries())
.filter(([id, data]) => data.interval < 10000 && data.reducible);
console.log(`Found ${potentialPolling.length} potential polling operations to optimize`);
}
pauseWebSocketHeartbeats() {
// Dispatch event for WebSocket connections to reduce heartbeat frequency
document.dispatchEvent(new CustomEvent('reduceWebSocketActivity'));
}
resumeWebSocketHeartbeats() {
document.dispatchEvent(new CustomEvent('restoreWebSocketActivity'));
}
suspendHeavyOperations() {
document.dispatchEvent(new CustomEvent('suspendHeavyOperations'));
}
resumeHeavyOperations() {
document.dispatchEvent(new CustomEvent('resumeHeavyOperations'));
}
reduceVisualUpdates() {
// Reduce update frequency for charts, graphs, etc.
document.dispatchEvent(new CustomEvent('reduceVisualUpdates'));
}
restoreVisualUpdates() {
document.dispatchEvent(new CustomEvent('restoreVisualUpdates'));
}
// Public API
setPerformanceMode(mode) {
if (['normal', 'power-saver', 'aggressive'].includes(mode)) {
this.performanceMode = mode;
console.log(`Performance mode set to: ${mode}`);
}
}
getOptimizationStatus() {
return {
performanceMode: this.performanceMode,
isOptimized: document.hidden,
intervals: this.optimizations.intervals.size,
animations: this.optimizations.animations.size,
pollingTasks: this.optimizations.pollingTasks.size,
mediaElements: this.optimizations.mediaElements.size,
batteryLevel: this.batteryInfo ? this.batteryInfo.level : null
};
}
}
// Initialize resource optimization
const resourceManager = new ResourceOptimizationManager();
// Example usage - register operations for optimization
resourceManager.registerPollingTask('api_status', () => {
// Check API status
fetch('/api/status').then(response => {
console.log('API status check');
});
}, 5000, { reducible: true });
// Register critical polling that shouldn't be reduced
resourceManager.registerPollingTask('critical_alerts', () => {
// Check for critical alerts
fetch('/api/alerts/critical').then(response => {
console.log('Critical alerts check');
});
}, 10000, { critical: true, reducible: false });
// Listen for optimization events
document.addEventListener('performMemoryCleanup', () => {
// Application-specific memory cleanup
window.applicationCache = {};
console.log('Application memory cleaned up');
});
document.addEventListener('suspendHeavyOperations', () => {
// Stop heavy computational tasks
console.log('Suspending heavy operations');
});
Best Practices for Smooth User Transitions
Create seamless experiences when users switch between tabs:
class SmoothTransitionManager {
constructor() {
this.tabManager = new TabVisibilityManager();
this.transitionState = {
isTransitioning: false,
lastVisibilityChange: Date.now(),
transitionQueue: []
};
this.animations = new Map();
this.stateSnapshots = new Map();
this.init();
}
init() {
this.tabManager
.onHidden((data) => this.handleHiddenTransition(data))
.onVisible((data) => this.handleVisibleTransition(data));
}
handleHiddenTransition(data) {
if (this.transitionState.isTransitioning) return;
this.transitionState.isTransitioning = true;
console.log('Starting hidden transition');
// Save current state
this.saveCurrentState();
// Queue transition animations
this.queueTransition('fadeOut', () => this.fadeOutNonEssentials());
this.queueTransition('pauseAnimations', () => this.pauseAllAnimations());
this.queueTransition('saveProgress', () => this.saveUserProgress());
this.executeTransitionQueue().then(() => {
this.transitionState.isTransitioning = false;
});
}
handleVisibleTransition(data) {
if (this.transitionState.isTransitioning) return;
this.transitionState.isTransitioning = true;
console.log('Starting visible transition');
// Calculate time away
const timeAway = data.timeInPreviousState;
// Queue transition animations based on time away
if (timeAway > 30000) { // More than 30 seconds
this.queueTransition('welcome', () => this.showWelcomeBackAnimation());
}
this.queueTransition('restoreState', () => this.restoreState());
this.queueTransition('fadeIn', () => this.fadeInElements());
this.queueTransition('resumeAnimations', () => this.resumeAllAnimations());
this.queueTransition('refreshData', () => this.refreshStaleData(timeAway));
this.executeTransitionQueue().then(() => {
this.transitionState.isTransitioning = false;
});
}
queueTransition(name, action, delay = 0) {
this.transitionState.transitionQueue.push({
name,
action,
delay,
timestamp: Date.now()
});
}
async executeTransitionQueue() {
console.log(`Executing ${this.transitionState.transitionQueue.length} transitions`);
for (const transition of this.transitionState.transitionQueue) {
try {
if (transition.delay > 0) {
await this.delay(transition.delay);
}
await transition.action();
console.log(`Completed transition: ${transition.name}`);
} catch (error) {
console.error(`Failed transition: ${transition.name}`, error);
}
}
this.transitionState.transitionQueue = [];
}
saveCurrentState() {
const state = {
scrollPosition: {
x: window.pageXOffset,
y: window.pageYOffset
},
formData: this.collectFormData(),
activeElement: document.activeElement ? {
tagName: document.activeElement.tagName,
id: document.activeElement.id,
className: document.activeElement.className,
selectionStart: document.activeElement.selectionStart,
selectionEnd: document.activeElement.selectionEnd
} : null,
timestamp: Date.now()
};
this.stateSnapshots.set('beforeHidden', state);
console.log('Current state saved');
}
restoreState() {
const state = this.stateSnapshots.get('beforeHidden');
if (!state) return;
// Restore scroll position smoothly
if (state.scrollPosition) {
window.scrollTo({
left: state.scrollPosition.x,
top: state.scrollPosition.y,
behavior: 'smooth'
});
}
// Restore form data
this.restoreFormData(state.formData);
// Restore focus
if (state.activeElement) {
this.restoreFocus(state.activeElement);
}
console.log('State restored');
}
collectFormData() {
const formData = {};
document.querySelectorAll('input, textarea, select').forEach((element, index) => {
if (element.type !== 'password' && element.value) {
const key = element.name || element.id || `element_${index}`;
formData[key] = {
value: element.value,
type: element.type,
tagName: element.tagName
};
}
});
return formData;
}
restoreFormData(formData) {
if (!formData) return;
Object.entries(formData).forEach(([key, data]) => {
const element = document.querySelector(`[name="${key}"], #${key}`);
if (element && element.value !== data.value) {
element.value = data.value;
// Trigger change event
element.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
restoreFocus(elementInfo) {
let element = null;
if (elementInfo.id) {
element = document.getElementById(elementInfo.id);
} else if (elementInfo.className) {
element = document.querySelector(`.${elementInfo.className.split(' ')[0]}`);
} else {
element = document.querySelector(elementInfo.tagName);
}
if (element) {
setTimeout(() => {
element.focus();
// Restore text selection if applicable
if (elementInfo.selectionStart !== undefined && element.setSelectionRange) {
element.setSelectionRange(elementInfo.selectionStart, elementInfo.selectionEnd);
}
}, 100);
}
}
fadeOutNonEssentials() {
return new Promise((resolve) => {
const nonEssentialElements = document.querySelectorAll('.fade-on-hidden, .advertisement, .sidebar');
nonEssentialElements.forEach(element => {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0.3';
});
setTimeout(resolve, 300);
});
}
fadeInElements() {
return new Promise((resolve) => {
const elementsToFadeIn = document.querySelectorAll('[style*="opacity"]');
elementsToFadeIn.forEach(element => {
element.style.transition = 'opacity 0.5s ease-in';
element.style.opacity = '1';
});
setTimeout(resolve, 500);
});
}
showWelcomeBackAnimation() {
return new Promise((resolve) => {
const welcomeElement = document.createElement('div');
welcomeElement.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
border-radius: 10px;
font-family: Arial, sans-serif;
font-size: 18px;
font-weight: bold;
z-index: 1000000;
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
transition: transform 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
`;
welcomeElement.textContent = 'Welcome back! 👋';
document.body.appendChild(welcomeElement);
// Animate in
setTimeout(() => {
welcomeElement.style.transform = 'translate(-50%, -50%) scale(1)';
}, 50);
// Animate out
setTimeout(() => {
welcomeElement.style.transform = 'translate(-50%, -50%) scale(0)';
setTimeout(() => {
welcomeElement.remove();
resolve();
}, 400);
}, 2000);
});
}
pauseAllAnimations() {
return new Promise((resolve) => {
// Store current animations
const runningAnimations = document.getAnimations();
runningAnimations.forEach((animation, index) => {
this.animations.set(index, {
animation,
playState: animation.playState,
currentTime: animation.currentTime
});
animation.pause();
});
console.log(`Paused ${runningAnimations.length} animations`);
resolve();
});
}
resumeAllAnimations() {
return new Promise((resolve) => {
this.animations.forEach((data, index) => {
try {
if (data.playState === 'running') {
data.animation.currentTime = data.currentTime;
data.animation.play();
}
} catch (error) {
console.warn('Could not resume animation:', error);
}
});
this.animations.clear();
console.log('Resumed animations');
resolve();
});
}
saveUserProgress() {
return new Promise((resolve) => {
// Collect progress data
const progressData = {
timestamp: Date.now(),
url: window.location.href,
scrollProgress: (window.pageYOffset / (document.body.scrollHeight - window.innerHeight)) * 100,
formProgress: this.calculateFormProgress(),
timeOnPage: Date.now() - performance.timing.navigationStart,
interactionCount: this.getInteractionCount()
};
// Save to localStorage
localStorage.setItem('userProgress', JSON.stringify(progressData));
// Optionally send to server
this.sendProgressToServer(progressData);
console.log('User progress saved');
resolve();
});
}
calculateFormProgress() {
let totalFields = 0;
let filledFields = 0;
document.querySelectorAll('input, textarea, select').forEach(field => {
if (field.type !== 'hidden' && field.type !== 'submit') {
totalFields++;
if (field.value.trim()) {
filledFields++;
}
}
});
return totalFields > 0 ? (filledFields / totalFields) * 100 : 0;
}
getInteractionCount() {
// This would be tracked by your analytics system
return parseInt(sessionStorage.getItem('interactionCount') || '0');
}
async sendProgressToServer(progressData) {
try {
await fetch('/api/user/progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(progressData),
keepalive: true
});
} catch (error) {
console.error('Failed to send progress to server:', error);
}
}
refreshStaleData(timeAway) {
return new Promise(async (resolve) => {
// Define refresh thresholds
const refreshThresholds = {
light: 300000, // 5 minutes
moderate: 600000, // 10 minutes
aggressive: 1800000 // 30 minutes
};
let refreshLevel = 'none';
if (timeAway > refreshThresholds.aggressive) {
refreshLevel = 'aggressive';
} else if (timeAway > refreshThresholds.moderate) {
refreshLevel = 'moderate';
} else if (timeAway > refreshThresholds.light) {
refreshLevel = 'light';
}
if (refreshLevel !== 'none') {
console.log(`Refreshing stale data (level: ${refreshLevel})`);
await this.performDataRefresh(refreshLevel);
}
resolve();
});
}
async performDataRefresh(level) {
const refreshTasks = {
light: ['notifications', 'status'],
moderate: ['notifications', 'status', 'content', 'user_data'],
aggressive: ['notifications', 'status', 'content', 'user_data', 'full_page']
};
const tasks = refreshTasks[level] || [];
for (const task of tasks) {
try {
await this.refreshDataType(task);
} catch (error) {
console.error(`Failed to refresh ${task}:`, error);
}
}
}
async refreshDataType(type) {
switch (type) {
case 'notifications':
// Refresh notification count
const notificationResponse = await fetch('/api/notifications/count');
const notificationData = await notificationResponse.json();
this.updateNotificationUI(notificationData);
break;
case 'status':
// Refresh online status, connection status, etc.
document.dispatchEvent(new CustomEvent('refreshStatus'));
break;
case 'content':
// Refresh dynamic content areas
document.dispatchEvent(new CustomEvent('refreshContent'));
break;
case 'user_data':
// Refresh user-specific data
document.dispatchEvent(new CustomEvent('refreshUserData'));
break;
case 'full_page':
// Consider full page refresh for very stale data
if (confirm('This page has been inactive for a long time. Refresh for latest data?')) {
window.location.reload();
}
break;
}
}
updateNotificationUI(data) {
const notificationBadge = document.querySelector('.notification-badge');
if (notificationBadge) {
notificationBadge.textContent = data.count;
notificationBadge.style.display = data.count > 0 ? 'block' : 'none';
}
}
// Utility methods
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Public API
triggerCustomTransition(name, action, delay = 0) {
this.queueTransition(name, action, delay);
if (!this.transitionState.isTransitioning) {
this.transitionState.isTransitioning = true;
this.executeTransitionQueue().then(() => {
this.transitionState.isTransitioning = false;
});
}
}
getTransitionState() {
return {
isTransitioning: this.transitionState.isTransitioning,
queueLength: this.transitionState.transitionQueue.length,
lastChange: this.transitionState.lastVisibilityChange
};
}
}
// Initialize smooth transitions
const transitionManager = new SmoothTransitionManager();
// Example of custom transition
document.addEventListener('customDataUpdate', () => {
transitionManager.triggerCustomTransition('dataUpdate', async () => {
// Custom transition logic
const loadingIndicator = document.querySelector('.loading');
if (loadingIndicator) {
loadingIndicator.style.display = 'block';
}
// Simulate data update
await new Promise(resolve => setTimeout(resolve, 1000));
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
});
});
Avoiding Unexpected Behavior Across Browsers
Cross-Browser Compatibility Manager:
class CrossBrowserVisibilityManager {
constructor() {
this.browserInfo = this.detectBrowser();
this.visibilityAPI = this.getVisibilityAPI();
this.quirksHandler = this.setupQuirksHandler();
this.eventNormalizer = this.setupEventNormalizer();
this.init();
}
detectBrowser() {
const userAgent = navigator.userAgent;
const vendor = navigator.vendor;
let browser = 'unknown';
let version = 'unknown';
if (/Chrome/.test(userAgent) && /Google Inc/.test(vendor)) {
browser = 'chrome';
version = userAgent.match(/Chrome\/(\d+)/)?.[1];
} else if (/Firefox/.test(userAgent)) {
browser = 'firefox';
version = userAgent.match(/Firefox\/(\d+)/)?.[1];
} else if (/Safari/.test(userAgent) && /Apple Computer/.test(vendor)) {
browser = 'safari';
version = userAgent.match(/Version\/(\d+)/)?.[1];
} else if (/Edge/.test(userAgent)) {
browser = 'edge';
version = userAgent.match(/Edge\/(\d+)/)?.[1];
}
return { browser, version: parseInt(version) || 0 };
}
getVisibilityAPI() {
// Cross-browser visibility API detection
if (typeof document.hidden !== 'undefined') {
return {
hidden: 'hidden',
visibilityState: 'visibilityState',
visibilityChange: 'visibilitychange'
};
} else if (typeof document.webkitHidden !== 'undefined') {
return {
hidden: 'webkitHidden',
visibilityState: 'webkitVisibilityState',
visibilityChange: 'webkitvisibilitychange'
};
} else if (typeof document.mozHidden !== 'undefined') {
return {
hidden: 'mozHidden',
visibilityState: 'mozVisibilityState',
visibilityChange: 'mozvisibilitychange'
};
} else if (typeof document.msHidden !== 'undefined') {
return {
hidden: 'msHidden',
visibilityState: 'msVisibilityState',
visibilityChange: 'msvisibilitychange'
};
}
return null;
}
setupQuirksHandler() {
const quirks = {};
// Safari-specific quirks
if (this.browserInfo.browser === 'safari') {
quirks.safariBackgroundThrottle = true;
quirks.safariBlurDelay = 100; // Safari has delayed blur events
quirks.safariVisibilityInconsistent = this.browserInfo.version < 14;
}
// Firefox-specific quirks
if (this.browserInfo.browser === 'firefox') {
quirks.firefoxMinimizeDetection = this.browserInfo.version < 90;
quirks.firefoxFocusEvents = true;
}
// Chrome-specific quirks
if (this.browserInfo.browser === 'chrome') {
quirks.chromeBackgroundTabThrottling = this.browserInfo.version >= 57;
quirks.chromeAgressiveThrottling = this.browserInfo.version >= 88;
}
// Mobile browser quirks
if (/Mobile|Android|iPhone|iPad/.test(navigator.userAgent)) {
quirks.mobileAppSwitch = true;
quirks.mobileLockScreen = true;
quirks.mobileMemoryManagement = true;
}
return quirks;
}
setupEventNormalizer() {
return {
normalizeVisibilityEvent: (event) => {
return {
type: 'visibilitychange',
hidden: document[this.visibilityAPI.hidden],
visibilityState: document[this.visibilityAPI.visibilityState],
timestamp: Date.now(),
originalEvent: event,
browser: this.browserInfo.browser
};
},
addBrowserSpecificData: (eventData) => {
if (this.quirks.safariBackgroundThrottle && eventData.hidden) {
eventData.safariThrottled = true;
}
if (this.quirks.chromeAgressiveThrottling && eventData.hidden) {
eventData.chromeThrottled = true;
}
return eventData;
}
};
}
init() {
if (!this.visibilityAPI) {
console.warn('Page Visibility API not supported, falling back to focus/blur events');
this.setupFallbackEvents();
return;
}
this.setupVisibilityEvents();
this.setupBrowserSpecificHandling();
this.setupFallbackEvents(); // Additional layer for reliability
}
setupVisibilityEvents() {
document.addEventListener(this.visibilityAPI.visibilityChange, (event) => {
const normalizedEvent = this.eventNormalizer.normalizeVisibilityEvent(event);
const enhancedEvent = this.eventNormalizer.addBrowserSpecificData(normalizedEvent);
this.handleVisibilityChange(enhancedEvent);
});
}
setupFallbackEvents() {
// Focus/blur events as fallback
window.addEventListener('focus', () => {
const event = {
type: 'focus',
hidden: false,
visibilityState: 'visible',
timestamp: Date.now(),
fallback: true,
browser: this.browserInfo.browser
};
this.handleVisibilityChange(event);
});
window.addEventListener('blur', () => {
// Delay for Safari quirk
const delay = this.quirks.safariBlurDelay || 0;
setTimeout(() => {
const event = {
type: 'blur',
hidden: true,
visibilityState: 'hidden',
timestamp: Date.now(),
fallback: true,
browser: this.browserInfo.browser
};
this.handleVisibilityChange(event);
}, delay);
});
// Page hide/show events for mobile
if (this.quirks.mobileAppSwitch) {
window.addEventListener('pagehide', () => {
this.handleVisibilityChange({
type: 'pagehide',
hidden: true,
visibilityState: 'hidden',
timestamp: Date.now(),
mobile: true
});
});
window.addEventListener('pageshow', () => {
this.handleVisibilityChange({
type: 'pageshow',
hidden: false,
visibilityState: 'visible',
timestamp: Date.now(),
mobile: true
});
});
}
}
setupBrowserSpecificHandling() {
// Safari-specific handling
if (this.browserInfo.browser === 'safari') {
this.setupSafariWorkarounds();
}
// Firefox-specific handling
if (this.browserInfo.browser === 'firefox') {
this.setupFirefoxWorkarounds();
}
// Mobile-specific handling
if (this.quirks.mobileAppSwitch) {
this.setupMobileWorkarounds();
}
}
setupSafariWorkarounds() {
// Safari sometimes doesn't fire visibility events on tab switch
let safariCheckInterval;
const startSafariCheck = () => {
safariCheckInterval = setInterval(() => {
const actuallyHidden = document[this.visibilityAPI.hidden];
const lastKnownState = this.lastVisibilityState;
if (actuallyHidden !== lastKnownState) {
console.log('Safari visibility state correction needed');
this.handleVisibilityChange({
type: 'safari-correction',
hidden: actuallyHidden,
visibilityState: actuallyHidden ? 'hidden' : 'visible',
timestamp: Date.now(),
correction: true
});
}
}, 1000);
};
const stopSafariCheck = () => {
if (safariCheckInterval) {
clearInterval(safariCheckInterval);
safariCheckInterval = null;
}
};
// Start checking when potentially problematic
document.addEventListener(this.visibilityAPI.visibilityChange, (event) => {
if (document[this.visibilityAPI.hidden]) {
startSafariCheck();
} else {
stopSafariCheck();
}
});
}
setupFirefoxWorkarounds() {
// Firefox sometimes has issues with minimized windows
if (this.quirks.firefoxMinimizeDetection) {
let firefoxState = 'visible';
window.addEventListener('resize', () => {
// Detect minimization
if (window.outerHeight === 0 || window.outerWidth === 0) {
if (firefoxState !== 'minimized') {
firefoxState = 'minimized';
this.handleVisibilityChange({
type: 'firefox-minimize',
hidden: true,
visibilityState: 'hidden',
timestamp: Date.now(),
minimized: true
});
}
} else if (firefoxState === 'minimized') {
firefoxState = 'visible';
this.handleVisibilityChange({
type: 'firefox-restore',
hidden: false,
visibilityState: 'visible',
timestamp: Date.now(),
restored: true
});
}
});
}
}
setupMobileWorkarounds() {
// Mobile devices have additional complexity
let mobileState = 'active';
// Handle orientation changes
window.addEventListener('orientationchange', () => {
setTimeout(() => {
// Check if still visible after orientation change
if (!document[this.visibilityAPI.hidden]) {
this.handleVisibilityChange({
type: 'orientation-change',
hidden: false,
visibilityState: 'visible',
timestamp: Date.now(),
orientationChange: true
});
}
}, 500);
});
// Handle app lifecycle events
document.addEventListener('resume', () => {
this.handleVisibilityChange({
type: 'app-resume',
hidden: false,
visibilityState: 'visible',
timestamp: Date.now(),
appResume: true
});
});
document.addEventListener('pause', () => {
this.handleVisibilityChange({
type: 'app-pause',
hidden: true,
visibilityState: 'hidden',
timestamp: Date.now(),
appPause: true
});
});
}
handleVisibilityChange(eventData) {
this.lastVisibilityState = eventData.hidden;
// Log browser-specific information
console.log('Visibility change:', {
browser: this.browserInfo.browser,
version: this.browserInfo.version,
event: eventData,
quirks: this.quirks
});
// Emit normalized event
document.dispatchEvent(new CustomEvent('normalizedVisibilityChange', {
detail: eventData
}));
}
// Public API for checking browser compatibility
getBrowserCompatibility() {
return {
browserInfo: this.browserInfo,
hasVisibilityAPI: !!this.visibilityAPI,
quirks: this.quirks,
supportLevel: this.calculateSupportLevel()
};
}
calculateSupportLevel() {
if (!this.visibilityAPI) return 'basic';
let score = 10;
// Deduct points for known issues
if (this.quirks.safariVisibilityInconsistent) score -= 2;
if (this.quirks.firefoxMinimizeDetection) score -= 1;
if (this.quirks.mobileMemoryManagement) score -= 1;
if (score >= 9) return 'excellent';
if (score >= 7) return 'good';
if (score >= 5) return 'fair';
return 'basic';
}
}
// Initialize cross-browser compatibility
const crossBrowserManager = new CrossBrowserVisibilityManager();
// Use normalized events
document.addEventListener('normalizedVisibilityChange', (event) => {
const { detail } = event;
if (detail.hidden) {
console.log('Tab hidden (normalized):', detail);
// Handle tab hidden with browser-specific context
} else {
console.log('Tab visible (normalized):', detail);
// Handle tab visible with browser-specific context
}
});
// Check compatibility
const compatibility = crossBrowserManager.getBrowserCompatibility();
console.log('Browser compatibility:', compatibility);
Conclusion
Implementing browser tab change detection with JavaScript’s Page Visibility API opens up powerful possibilities for creating more responsive, efficient, and user-friendly web applications. From automatically pausing media when users switch tabs to implementing sophisticated security measures that protect sensitive data during inactivity, tab visibility detection has become an essential tool in modern web development.
Recap of Key Techniques and Implementation Tips
Core Implementation Strategies:
- Use the Page Visibility API as your primary detection method, with focus/blur events as fallback
- Implement cross-browser compatibility layers to handle vendor prefixes and browser quirks
- Create debounced event handlers to prevent rapid-fire state changes from causing performance issues
- Build modular systems that can be easily extended with additional functionality
Performance Optimization Techniques:
- Pause resource-intensive operations like animations, polling, and media playback when tabs are hidden
- Implement intelligent caching strategies that adapt to user attention patterns
- Use battery-aware optimizations on mobile devices to preserve device power
- Create smooth transition effects that enhance rather than interrupt user experience
Security and Session Management:
- Implement graduated security measures based on inactivity duration and sensitivity levels
- Use tab visibility data to inform session timeout calculations and warnings
- Automatically mask sensitive information when tabs lose focus
- Provide clear user feedback about security actions and session status
Final Thoughts on Ethical Use and User Trust
When implementing tab visibility detection, always prioritize user privacy and trust. Be transparent about what your application does when tabs are hidden, and provide users with control over these behaviors when possible. Avoid overly aggressive tracking or resource consumption that could negatively impact user experience.
Ethical Guidelines:
- Clearly communicate in your privacy policy how tab visibility affects application behavior
- Provide users with options to disable automatic pausing or security features if appropriate
- Respect user bandwidth and battery life by being conservative with background activity
- Use tab detection to enhance, not replace, good fundamental application design
Encouragement to Enhance UX by Respecting User Behavior
The most successful implementations of tab visibility detection feel invisible to users—they simply experience a more responsive and intuitive application. Focus on solving real user problems rather than implementing features for their own sake.
Consider tab switching as a natural part of modern browsing behavior, not an interruption to manage. Your application should gracefully adapt to user attention patterns while preserving their work and providing seamless transitions between focused and background states.
Start with simple implementations like pausing media or saving form data, then gradually add more sophisticated features as you understand your users’ specific needs and workflows. Remember that the goal is always to create better user experiences, not to exert control over user behavior.
By thoughtfully implementing tab visibility detection, you’ll create applications that feel more alive and responsive while respecting the multi-tasking nature of modern web usage. The techniques covered in this guide provide a solid foundation for building these enhanced experiences while maintaining performance, security, and user trust.