Idle Task Scheduling with PowerShell
Running maintenance work during user idle time is a sound strategy: the machine is powered on, no interactive work is competing for CPU and I/O, and the task completes without the user noticing it ran. Windows Task Scheduler exposes an idle trigger, but the built-in idle trigger has enough edge cases — screensavers, locked screens, remote sessions, media playback — that relying on it uncritically produces tasks that either never run or run at the wrong time. This page is part of the development section and is relevant context if you have read the TwinUI technical note and are thinking about background processes in the Windows shell environment. The how-to guides cover adjacent topics for configuration tasks that complement scripted automation. The sections here cover what Windows considers idle, where Task Scheduler's built-in detection fails, how to implement reliable idle detection in PowerShell using the GetLastInputInfo Win32 API, practical task patterns, and when a simpler approach is sufficient.
The short version
Windows Task Scheduler's built-in idle trigger uses the system's idle detection heuristic, which considers CPU usage, disk I/O, and user input. It has documented failures: it does not trigger during full-screen video playback, it behaves inconsistently on locked screens and Remote Desktop sessions, and the "wait for idle for X minutes" setting can prevent tasks from running for hours on a busy system. For reliable idle detection in PowerShell, call the Win32 GetLastInputInfo API directly to measure time since last user input, implement your own idle threshold check in a polling loop, and gate task execution on that check rather than on the Task Scheduler idle trigger.
What Windows considers idle
Windows idle detection is not a single API — it is a collection of heuristics used by different subsystems for different purposes, and they do not agree.
Task Scheduler idle detection: Uses a combination of CPU idle time (CPU usage below a threshold), disk I/O idle, and absence of user input. The thresholds are not fully documented. The "idle" state as seen by Task Scheduler is based on the overall system state, not specifically on user input absence.
GetLastInputInfo API: Returns the tick count of the last keyboard or mouse input event. This measures only user input — it does not consider CPU or disk usage. A machine running a batch job with no user input will appear "idle" by this measure even if the CPU is fully loaded.
Screen saver idle timer: Uses the display idle timeout configured in Windows power settings. This is purely input-based and is the closest to "the user hasn't touched the computer in N minutes."
For background task scheduling, GetLastInputInfo is the most useful measure: you want to know whether the user is actively using the machine, not whether the system's CPU is busy. A backup job that runs while a compile is in progress is annoying; a backup job that runs while the user has gone to get coffee is not.
Where Task Scheduler's idle trigger fails
The OnIdle trigger in Task Scheduler works for simple cases. The failures matter for scheduled maintenance:
On a Windows 11 machine with a Task Scheduler job configured with an idle trigger set to "wait until idle for 10 minutes, stop if computer ceases to be idle," the job failed to run on evenings when the machine was displaying a Netflix stream in full-screen Edge. The browser's media playback state prevented the system from reaching the idle threshold used by Task Scheduler, even though no user input had occurred for over an hour. Moving the mouse triggered input detection and reset the idle timer — but no mouse movement had occurred. The cause was that media playback prevents the display idle timeout from activating, and Task Scheduler's idle detection is coupled to this state.
Remote Desktop sessions: When a machine is accessed via Remote Desktop, the local console session may be disconnected. Task Scheduler tasks configured to run only when a user is logged in may not run in a disconnected RDP session. Tasks configured to run whether or not a user is logged in ignore idle state entirely. The interaction between session state and idle detection is poorly documented.
Locked screens: A locked screen is not the same as idle. User input occurred at lock time. After locking, GetLastInputInfo continues to record the lock time as the last input, not the time the machine was unlocked. A machine that has been locked for eight hours may not appear idle by Task Scheduler's heuristic if the lock event reset some internal state.
The Task Scheduler "Stop the task if the computer ceases to be idle" setting sounds conservative but causes tasks to abort at unpredictable points. A backup job that stops midway because the user pressed a key is worse than a backup job that did not start. For maintenance tasks that should run to completion, omit this setting and rely on your task's own completion logic, or use a timeout that is independent of idle state.
Implementing idle detection in PowerShell
The GetLastInputInfo Win32 API returns a LASTINPUTINFO structure containing the tick count of the last input event. Comparing this to the current tick count gives elapsed time since last input in milliseconds.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class IdleDetection {
[StructLayout(LayoutKind.Sequential)]
private struct LASTINPUTINFO {
public uint cbSize;
public uint dwTime;
}
[DllImport("user32.dll")]
private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
public static TimeSpan GetIdleTime() {
LASTINPUTINFO lastInput = new LASTINPUTINFO();
lastInput.cbSize = (uint)Marshal.SizeOf(lastInput);
GetLastInputInfo(ref lastInput);
uint idleTickCount = (uint)Environment.TickCount - lastInput.dwTime;
return TimeSpan.FromMilliseconds(idleTickCount);
}
public static bool IsIdle(int thresholdSeconds) {
return GetIdleTime().TotalSeconds >= thresholdSeconds;
}
}
"@
# Usage:
$idleTime = [IdleDetection]::GetIdleTime()
Write-Host "Idle for: $($idleTime.TotalMinutes.ToString('F1')) minutes"
The Environment.TickCount wraps at approximately 24.9 days of uptime. For production use on servers with long uptimes, use Environment.TickCount64 (available in .NET 5+ / PowerShell 7+) to avoid the wrap-around.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class IdleDetectionSafe {
[StructLayout(LayoutKind.Sequential)]
private struct LASTINPUTINFO {
public uint cbSize;
public uint dwTime;
}
[DllImport("user32.dll")]
private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
public static TimeSpan GetIdleTime() {
LASTINPUTINFO lastInput = new LASTINPUTINFO();
lastInput.cbSize = (uint)Marshal.SizeOf(lastInput);
GetLastInputInfo(ref lastInput);
// Use TickCount64 for wrap safety on long-running systems
long currentTick = Environment.TickCount64;
long lastInputTick = (long)lastInput.dwTime;
// Handle the 32-bit tick count wrap in dwTime
long elapsed = (currentTick & 0xFFFFFFFF00000000L) | lastInputTick;
if (elapsed > currentTick) elapsed -= 0x100000000L;
return TimeSpan.FromMilliseconds(currentTick - elapsed);
}
}
"@
A practical idle task runner
With reliable idle detection in place, a polling loop provides the scheduling backbone:
param(
[int]$IdleThresholdMinutes = 10,
[int]$PollIntervalSeconds = 60
)
# Define tasks as script blocks with names
$taskQueue = [System.Collections.Generic.Queue[hashtable]]::new()
$taskQueue.Enqueue(@{ Name = 'Optimise SQLite DBs'; Action = { & "$PSScriptRoot\tasks\optimise-dbs.ps1" } })
$taskQueue.Enqueue(@{ Name = 'Rotate log files'; Action = { & "$PSScriptRoot\tasks\rotate-logs.ps1" } })
$taskQueue.Enqueue(@{ Name = 'Verify backups'; Action = { & "$PSScriptRoot\tasks\verify-backups.ps1" } })
$logPath = "$env:LOCALAPPDATA\IdleScheduler\runner.log"
New-Item -ItemType Directory -Force -Path (Split-Path $logPath) | Out-Null
function Write-Log {
param([string]$Message)
$entry = "[{0:yyyy-MM-dd HH:mm:ss}] {1}" -f (Get-Date), $Message
Add-Content -Path $logPath -Value $entry
Write-Host $entry
}
Write-Log "Idle task runner started. Threshold: $IdleThresholdMinutes min, Poll: $PollIntervalSeconds sec"
while ($taskQueue.Count -gt 0) {
$idleTime = [IdleDetection]::GetIdleTime()
if ($idleTime.TotalMinutes -ge $IdleThresholdMinutes) {
$task = $taskQueue.Dequeue()
Write-Log "Starting task: $($task.Name) (idle for $($idleTime.TotalMinutes.ToString('F1')) min)"
try {
& $task.Action
Write-Log "Completed task: $($task.Name)"
} catch {
Write-Log "ERROR in task $($task.Name): $_"
}
} else {
Write-Log "Not idle ($($idleTime.TotalMinutes.ToString('F1')) min < $IdleThresholdMinutes min). Waiting."
Start-Sleep -Seconds $PollIntervalSeconds
}
}
Write-Log "All tasks complete."
Registering the runner with Task Scheduler
The idle task runner script itself needs to be launched. The most reliable approach: register it as a Task Scheduler task triggered at user logon with the "run only when user is logged in" and "wake to run" options off. The script then handles idle detection internally.
$action = New-ScheduledTaskAction `
-Execute 'pwsh.exe' `
-Argument '-NonInteractive -WindowStyle Hidden -File "C:\Scripts\idle-runner.ps1"'
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 4) `
-RunOnlyIfNetworkAvailable $false `
-WakeToRun $false `
-StartWhenAvailable $true
Register-ScheduledTask `
-TaskName 'IdleMaintenanceRunner' `
-TaskPath '\Custom\' `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-RunLevel Limited `
-Force
The runner script above uses pwsh.exe (PowerShell 7+). For Windows PowerShell 5.1, change to powershell.exe and use Environment.TickCount instead of TickCount64. PowerShell 7 is the better choice for new automation work — it runs side-by-side with Windows PowerShell 5.1 and does not affect existing scripts.
When to use Task Scheduler's built-in idle trigger
The implementation above is more work than a simple Task Scheduler idle trigger. The built-in trigger is appropriate when:
- The task is idempotent and can abort midway: A disk defragmentation or Windows Update download that stops when the user returns and resumes next time is fine with the built-in trigger.
- Reliability is a secondary concern: A "nice to have" optimisation task that runs whenever convenient and skips runs with no consequence.
- The system is a dedicated workstation with predictable idle patterns: A workstation that is genuinely unused overnight and not running media playback will reliably trigger the built-in idle heuristic.
The custom idle detection path is warranted when:
- The task must run to completion: Partial runs leave inconsistent state (a backup that did not finish, a database optimisation that stopped midway).
- Media playback or other activities prevent the built-in trigger: Machines used for home theatre, video editing, or rendering where the system is frequently in a "busy but no user interaction" state.
- You need audit logging of when tasks ran and what they did: Task Scheduler's history log is limited; a custom runner writes whatever log entries you define.
Windows 11 Task Scheduler is functionally identical to Windows 10's for the purposes discussed here. The underlying engine has not changed significantly since Windows 7. The idle trigger limitations are long-standing and have not been addressed. PowerShell 7's cross-platform support means idle detection scripts written against the Windows API (as above) will not be portable to Linux/macOS, but within the Windows context, the approach works on any PowerShell version from 5.1 onward with minor syntax adjustments.