To monitor Windows Task Scheduler, make each task ping a heartbeat URL at the end of a successful run, and alert when that ping does not arrive on schedule. Task Scheduler cannot do this for you: it records that it launched the action, and in my tests it logged "successfully completed" for a script that failed with exit code 16.

I'm Léo, I build Hyperping. This guide covers how scheduled tasks fail without anyone noticing, what Windows shows you natively, and the PowerShell setup I use with Hyperping healthchecks. I registered and ran every task below on Windows 11 with Windows PowerShell 5.1, as SYSTEM.

Key takeaways

  • A task whose action exits non-zero still logs event 201 "successfully completed" and event 102 "successfully finished". Only Last Run Result shows the real code, and nothing alerts on it.
  • Missed runs are skipped by default. -StartWhenAvailable runs them after the machine wakes up, and the cmdlet's defaults refuse to start on battery.
  • Launch PowerShell with -File, not -Command, and check $LASTEXITCODE after native programs. Robocopy needs special care: exit codes 1 to 7 mean success.
  • Use Invoke-WebRequest -UseBasicParsing in Windows PowerShell 5.1. Since KB5074596 (December 2025), the prompt it shows otherwise can hang a scheduled task.
  • A heartbeat sent only on success catches failed runs, skipped runs, hung runs and a machine that is off, with one alert rule.

How Windows scheduled tasks fail silently

"Successfully completed" does not mean it worked

Task Scheduler judges whether it launched the action. I registered a task whose script exited 16 and read the Operational log:

201: Task Scheduler successfully completed task "\HyperpingGuideTest\exit16" , instance "{2efc7225-...}" , action "cmd.exe" with return code 2147942416.
102: Task Scheduler successfully finished "{2efc7225-...}" instance of the "\HyperpingGuideTest\exit16" task for user "NT AUTHORITY\SYSTEM".

2147942416 is 0x80070010, the exit code 16 wrapped in an HRESULT. The task list shows Last Run Result: 0x10 and the history shows success. No email, no event with an error level.

PowerShell swallows exit codes

Two details decide whether Task Scheduler sees your script's failure at all:

  • -File versus -Command. The powershell.exe docs say that with -Command, an exit code "other than 0 or 1" is "converted to 1". I checked: a script ending with exit 16 returned 16 through -File and 1 through -Command.
  • Native programs. $ErrorActionPreference = 'Stop' turns PowerShell errors into script-terminating errors, but a native .exe returning 2 is not an error in Windows PowerShell 5.1. You have to read $LASTEXITCODE, which holds "the exit code of the last native program or PowerShell script that ran".

Robocopy is the trap everybody hits: exit codes 0 to 7 mean success (1 means files were copied), 8 and above mean failure. A script that does exit $LASTEXITCODE after a successful robocopy reports a failure every night.

Missed runs are skipped

If the computer is asleep, off, or rebooting for updates at 02:00, the run is skipped. "Run task as soon as possible after a scheduled start is missed" (StartWhenAvailable) is off by default. "Wake the computer to run this task" (WakeToRun) is off by default too.

On laptops, the battery conditions add another silent skip. I printed the defaults of New-ScheduledTaskSettingsSet on Windows 11:

PS> $d = New-ScheduledTaskSettingsSet
PS> $d.DisallowStartIfOnBatteries, $d.StopIfGoingOnBatteries, $d.StartWhenAvailable, $d.ExecutionTimeLimit
True
True
False
PT72H

So a task created with default settings does not start on battery, stops if the laptop is unplugged, and can run for 72 hours before Windows stops it.

A hung run blocks the next ones

The default for a task that is still running when its next trigger fires is IgnoreNew: the new run is dropped, which the history logs as event 322 (launch request ignored, instance already running). A script waiting forever on a network share, or on the Invoke-WebRequest prompt below, can block a daily task for up to three days.

The run-as account loses access

"Run whether user is logged on or not" stores the user's password. When the password changes, the task fails to log on and never starts. Ticking "Do not store password" uses S4U, which "can only use the security context to access local resources", so a backup to a network share fails. For local jobs, running as SYSTEM avoids both.

Invoke-WebRequest can hang the task

Since the December 2025 updates (CVE-2025-54100, KB5074596), Invoke-WebRequest in Windows PowerShell 5.1 shows a "Script Execution Risk" prompt when it parses a response, and Microsoft notes the prompt "could cause the script to hang while waiting for input". -UseBasicParsing skips it. Any heartbeat script for 5.1 needs that switch.

How to check scheduled tasks with PowerShell

# Last run, its result and the next run
Get-ScheduledTaskInfo -TaskName 'Nightly backup' |
    Select-Object LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns

# Same information, plus the account and logon mode
schtasks /query /tn 'Nightly backup' /v /fo LIST

# Task history is off on many machines: turn it on
wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true

# Last events for the task
Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-TaskScheduler/Operational'; StartTime = (Get-Date).AddDays(-1) } |
    Where-Object Message -like '*Nightly backup*' |
    Select-Object TimeCreated, Id, Message

LastTaskResult is the number to read:

Last Run Result Meaning
0x0 The action exited 0
0x1, 0x10... The action's own exit code (1, 16...)
0x41301 The task is running now
0x41303 The task has never run
0x41306 The task was terminated by a user
0x8004131F Refused: an instance is already running

Task history was disabled on the Windows 11 machine I used. Once enabled, the events I saw for a normal run were 107 (time trigger), 100 (task started), 200 (action launched), 201 (action completed, with its return code) and 102 (task finished). These commands only help if someone runs them.

How to monitor Windows Task Scheduler with Hyperping

A Hyperping healthcheck gives the task a secret URL. When the success ping does not arrive by the scheduled time plus a grace period, Hyperping opens an incident and alerts you, and it resolves on the next successful ping. Healthchecks are included on every plan, Free included.

1. Create a healthcheck with the task's schedule

In Hyperping, open Healthchecks, click Create healthcheck, and pick Cron. Write the trigger as a cron expression: a daily trigger at 2:00 AM is 0 2 * * *, a weekly one on Mondays at 6:00 AM is 0 6 * * 1. For the timezone, run Get-TimeZone on the machine and pick the matching IANA name: Romance Standard Time is Europe/Paris, Eastern Standard Time is America/New_York.

For a trigger that repeats every N minutes, use simple mode with the same interval, or the cron generator's every 15 minutes and every hour pages to check the expression.

2. Store the ping URL where only SYSTEM can read it

New-Item -ItemType Directory -Force 'C:\ProgramData\Hyperping' | Out-Null
icacls 'C:\ProgramData\Hyperping' /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F'
'https://hc.hyperping.io/tok_your_backup_token' | Set-Content 'C:\ProgramData\Hyperping\backup-url.txt'

The two SIDs are SYSTEM and the local Administrators group, so the command works whatever the Windows display language. The URL is the healthcheck's only credential.

3. Wrap the job in a PowerShell script that pings on success

# C:\Scripts\backup-with-heartbeat.ps1
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

$Source = 'D:\Data'
$Destination = 'E:\Backups\Data'
$HcUrl = (Get-Content -Raw 'C:\ProgramData\Hyperping\backup-url.txt').Trim()

function Send-Heartbeat([string]$Url) {
    try {
        Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 10 | Out-Null
    } catch {
        Write-Warning "Hyperping ping failed: $($_.Exception.Message)"
    }
}

Send-Heartbeat "$HcUrl/start"

# Robocopy exit codes 0 to 7 mean success, 8 and above mean failure.
robocopy $Source $Destination /MIR /R:2 /W:5 /NP /LOG:C:\ProgramData\Hyperping\backup.log
if ($LASTEXITCODE -ge 8) { exit $LASTEXITCODE }

Send-Heartbeat $HcUrl
exit 0

Replace the robocopy block with your own job. For most programs, the check becomes if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }. A PowerShell error anywhere else stops the script ($ErrorActionPreference = 'Stop'), which exits 1 under -File, so no success ping is sent.

The TLS 1.2 line only matters on older Windows Server builds where .NET does not enable it by default. The heartbeat function never throws, so a network problem never turns a good backup into a failed task.

4. Register the task with settings that do not skip runs

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\backup-with-heartbeat.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 2am
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun `
    -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
    -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName 'Nightly backup' -Action $action -Trigger $trigger `
    -Settings $settings -Principal $principal

-ExecutionTimeLimit of one hour replaces the 72 hour default, so a hung run is stopped long before the next night. If the job needs a network share, run it as a domain account with a stored password instead of SYSTEM, and expect to update the task when that password changes.

Here is what I got on Windows 11 with this exact script, registered as SYSTEM, and a local listener in place of Hyperping:

Run Pings received Last Run Result
Time trigger, robocopy copies a file (exit 1) /start, then success 0x0
Source folder missing, robocopy exits 16 /start only 0x10
Robocopy succeeds, ping endpoint down none 0x0

The requests arrived with the user agent WindowsPowerShell/5.1, which is what you will see in the healthcheck's ping list.

5. Size the grace period and route the alerts

In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period. Give it the job's usual run time plus a margin: 30 minutes for a backup that takes 10. After a few days, the healthcheck's ping history shows each run's duration between /start and success.

With -StartWhenAvailable, a run missed while the machine was asleep alerts at 02:00 plus the grace period, then the catch-up run after wake-up sends the ping and closes the incident.

A missed ping alerts every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies, on-call schedules or Microsoft Teams, so if a failed backup should page the person on call, route it to PagerDuty or Opsgenie.

Test it before the first night

  1. Run Start-ScheduledTask -TaskName 'Nightly backup' and check that both pings appear in the healthcheck's last pings.
  2. Point $Source at a folder that does not exist and start the task again. Last Run Result becomes 0x10, only /start arrives, and the alert reaches you after the grace period.
  3. Restore the path and run it once more: the incident closes.

The same pattern works for systemd timers on Linux, Kubernetes CronJobs, GitHub Actions scheduled workflows and the Laravel scheduler. For Windows hosts themselves, see the best Windows server monitoring tools. Whatever the host, a nightly backup needs more than an exit code: see monitoring database backups.