Harnessing the Idle Detection API for Responsive, Power-Efficient Web Applications

Thumb twiddling

Modern web applications increasingly rely on real-time state synchronization, live collaboration, and background processing. However, keeping heavy tasks—such as WebSocket heartbeat polling, high-frequency chart rendering, or video canvas re-paints—active while a user is away from their keyboard drains battery and wastes system resources.

The Idle Detection API provides a native browser interface to detect when a user is inactive or when their screen locks. Instead of relying on fragile mouse-movement timers or continuous event listeners, developers can subscribe to native operational state changes directly from the underlying OS.


Key Requirements & Security Prerequisites

Before deploying the Idle Detection API to production, ensure your application satisfies the following browser policies:

  • Secure Context (HTTPS): The API is restricted to secure origins (https://).
  • Explicit Permission (idle-detection): The browser requires user authorization via IdleDetector.requestPermission().
  • Transient Activation: Permission requests must be triggered by a user gesture (e.g., a button click).
  • Feature-Policy / Permissions Policy: If loaded inside an iframe, the document must explicitly allow idle-detection.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Idle Detection API Demo</title>
    <link href="style.css" rel="stylesheet" />
    <script src="idleDetection_API.js" defer></script>
</head>
    
<body>
    <main class="container">
        <header class="header">
            <h1>Idle Detection Monitor</h1>
            <p class="subtitle">Track real-time user and screen idle transitions.</p>
        </header>
    
        <!-- Trigger button to request user consent -->
        <section class="controls">
            <button id="start-btn" class="btn">Start Monitoring</button>
            <span id="permission-status" class="status-badge">Permission: Not Requested</span>
        </section>

        <!-- Indicator lights showing current state -->
        <section class="state-indicators">
            <div class="indicator-card">
                <span class="label">User State</span>
                <span id="user-state" class="state-value">Unknown</span>
            </div>
            <div class="indicator-card">
                <span class="label">Screen State</span>
                <span id="screen-state" class="state-value">Unknown</span>
            </div>
        </section>
        
        <!-- Live event log window -->
        <section class="log-section">
            <h2>Activity Log</h2>
            <ul id="log-list" class="log-list">
                <li class="log-item info">Click "Start Monitoring" to request permission.</li>
            </ul>
        </section>
    </main>
</body>

</html>

/* Base Layout & Responsive Styling */
:root {
    --bg-color: #0f172a;
    --card-bg: #1e293b;
    --text-main: #f8fafc;
    --text-muted: #94a3b8;
    --accent-color: #2563eb;
    --accent-hover: #1d4ed8;
    --border-color: #334155;
    --active-color: #22c55e;
    --idle-color: #eab308;
    --locked-color: #ef4444;
}

body {
    margin: 0;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: var(--bg-color);
    color: var(--text-main);
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    padding: 1.5rem;
    box-sizing: border-box;
}

.container {
    width: 100%;
    max-width: 680px;
    background-color: var(--card-bg);
    border: 1px solid var(--border-color);
    border-radius: 12px;
    padding: 2rem;
    box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
}

.header h1 {
    margin: 0 0 0.5rem 0;
    font-size: 1.75rem;
    font-weight: 700;
}

.subtitle {
    margin: 0 0 1.5rem 0;
    color: var(--text-muted);
    font-size: 0.95rem;
}

/* Control Panel */
.controls {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 1.5rem;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid var(--border-color);
}

.btn {
    background-color: var(--accent-color);
    color: #fff;
    border: none;
    padding: 0.75rem 1.25rem;
    font-size: 0.95rem;
    font-weight: 600;
    border-radius: 6px;
    cursor: pointer;
    transition: background-color 0.2s ease;
}

.btn:hover {
    background-color: var(--accent-hover);
}

.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.status-badge {
    font-size: 0.85rem;
    color: var(--text-muted);
    background: var(--bg-color);
    padding: 0.4rem 0.75rem;
    border-radius: 4px;
    border: 1px solid var(--border-color);
}

/* State Indicators */
.state-indicators {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1rem;
    margin-bottom: 1.5rem;
}

.indicator-card {
    background-color: var(--bg-color);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.label {
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
}

.state-value {
    font-size: 1.25rem;
    font-weight: 700;
    color: var(--text-main);
}

/* Activity Log */
.log-section h2 {
    font-size: 1.1rem;
    margin: 0 0 0.75rem 0;
    color: var(--text-muted);
}

.log-list {
    list-style: none;
    padding: 0;
    margin: 0;
    max-height: 220px;
    overflow-y: auto;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    background-color: var(--bg-color);
    font-family: monospace;
    font-size: 0.85rem;
}

.log-item {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--border-color);
    color: var(--text-main);
}

.log-item:last-child {
    border-bottom: none;
}

.log-item.info {
    color: var(--text-muted);
}

.log-item.active {
    color: var(--active-color);
}

.log-item.idle {
    color: var(--idle-color);
}

.log-item.locked {
    color: var(--locked-color);
}

// Store references to DOM elements using document.querySelector
const startBtn = document.querySelector('#start-btn');
const permissionStatus = document.querySelector('#permission-status');
const userStateEl = document.querySelector('#user-state');
const screenStateEl = document.querySelector('#screen-state');
const logList = document.querySelector('#log-list');

// Define minimum threshold for idle state triggering (in milliseconds; min 60,000 ms per spec)
const IDLE_THRESHOLD_MS = 60000;

// Helper function to append timestamped messages to the UI log list
const addLogEntry = (message, type = 'info') => {
	const timestamp = new Date().toLocaleTimeString();
	const li = document.createElement('li');
	li.className = `log-item ${type}`;
	li.textContent = `[${timestamp}] ${message}`;

	// Prepend to show the latest state changes at the top
	logList.prepend(li);
};

// Update status text in the UI indicator cards
const updateIndicators = (userState, screenState) => {
	userStateEl.textContent = userState;
	screenStateEl.textContent = screenState;
};

// Main function to initialize and start the IdleDetector
const initIdleDetection = async () => {
	// 1. Verify browser compatibility
	if (!('IdleDetector' in window)) {
		addLogEntry(
			'Error: Idle Detection API is not supported in this browser.',
			'locked'
		);
		permissionStatus.textContent = 'Status: Unsupported';
		return;
	}

	try {
		// 2. Request user permission (must be called from a user gesture)
		addLogEntry('Requesting Idle Detection permission...', 'info');
		const permission = await IdleDetector.requestPermission();
		permissionStatus.textContent = `Permission: ${permission}`;

		if (permission !== 'granted') {
			addLogEntry('Permission denied by user.', 'locked');
			return;
		}

		// 3. Instantiate the IdleDetector instance
		const idleDetector = new IdleDetector();

		// 4. Attach event listener for change events using arrow functions
		idleDetector.addEventListener('change', () => {
			const { userState, screenState } = idleDetector;

			// Log state transition
			addLogEntry(
				`State change — User: ${userState} | Screen: ${screenState}`,
				userState === 'active' ? 'active' : 'idle'
			);

			// Update DOM indicators
			updateIndicators(userState, screenState);

			// Example operational hooks:
			if (userState === 'idle' || screenState === 'locked') {
				// Pause resource-intensive processes (e.g., real-time polling, video canvas)
				addLogEntry('System paused: entering idle mode.', 'idle');
			} else if (userState === 'active') {
				// Resume background activity
				addLogEntry('System resumed: user returned active.', 'active');
			}
		});

		// 5. Start listening with a 60-second minimum threshold
		await idleDetector.start({
			threshold: IDLE_THRESHOLD_MS
		});

		addLogEntry(
			`Idle detector active (Threshold: ${IDLE_THRESHOLD_MS / 1000}s).`,
			'active'
		);

		// Set initial values
		updateIndicators(idleDetector.userState, idleDetector.screenState);

		// Disable start button once initialized
		startBtn.disabled = true;
		startBtn.textContent = 'Monitoring Active';
	} catch (error) {
		addLogEntry(`Initialization error: ${error.message}`, 'locked');
	}
};

// Attach click listener to start button using arrow function
startBtn.addEventListener('click', () => {
	initIdleDetection();
});

more posts: