Windows Task Scheduler runs a lot of quiet, important work: nightly SQL Server backups, report exports, file transfers to a partner's SFTP server, PowerShell scripts that sync Active Directory with an HR system, cleanup jobs on a file share. It has a graphical interface with a "Last Run Result" column, which is where most people stop. The column says The operation completed successfully (0x0), the "Last Run Time" is this morning, and everyone assumes the backup exists.

Task Scheduler is not lying, exactly. It is answering a narrower question than the one you're asking. "The operation completed successfully" means the process it launched returned exit code zero. Whether the script did anything, whether it ran on the schedule you set, or whether it ran at all last Tuesday are different questions, and the scheduler is silent on all of them. This guide covers the ways scheduled tasks fail without anyone noticing, and how to put a check outside the machine so the silence itself becomes an alert. The pattern is the same one we use for cron jobs and systemd timers; the failure modes are Windows-specific.

Eight ways a scheduled task fails silently

  1. The script failed and exited 0. This is the big one. PowerShell's default $ErrorActionPreference is Continue, so a Copy-Item that can't reach the destination writes red text and keeps going, and the script exits 0. A batch file returns the exit code of its last command, so backup.exe & echo Done is always a success. A wrapper that launches a program with Start-Process and doesn't -Wait returns before the child has done anything. In each case Last Run Result is 0x0 and the work didn't happen.
  2. The task is set to "Run only when user is logged on." It's the default for new tasks. On a workstation that's fine. On a server that rebooted for patches and is sitting at the login screen, the task will never fire until someone signs in, and there is no error because from the scheduler's point of view the conditions simply weren't met. Interactive tasks also die when the RDP session that owned them is logged off.
  3. The stored password expired. Tasks set to "Run whether user is logged on or not" store the account's credentials. When that account's password changes, or the account is disabled when someone leaves, the task fails to start with 0x8007052E (the logon failure code). It shows up in Last Run Result, but only if you look, and nobody looks at a task that's been fine for two years.
  4. Somebody disabled it. Troubleshooting a slow server, a colleague disables the nightly job "for now." The task stays in the list with Status "Disabled," and a disabled task is not an error. Or a task was never enabled on the new server after a migration, because exporting and importing tasks is a manual step that got missed.
  5. A condition skipped the run. The Conditions tab has "Start the task only if the computer is on AC power," which is checked by default. On a laptop or a UPS-backed machine in a brownout, the run is skipped. "Start only if the following network connection is available" and "Wake the computer to run this task" (off by default, so a sleeping machine sleeps through the trigger) do the same thing. Skipped runs are not failures.
  6. The machine was off, and the run wasn't rescheduled. "Run task as soon as possible after a scheduled start is missed" is off by default. A server down for maintenance during the 2 AM window just doesn't run that night. Combined with a weekly trigger, that's a week without a backup and no error anywhere.
  7. The previous run is still going. The default for "If the task is already running" is "Do not start a new instance." A backup that hung on a network share at 2 AM Monday blocks every subsequent run. Each one is logged as "launch request ignored, instance already running" (event 322) if history is on, and as nothing at all if it isn't. The default "Stop the task if it runs longer than" is three days.
  8. Task history is off. On most installs, the Task Scheduler event log ("Microsoft-Windows-TaskScheduler/Operational") is disabled by default until someone clicks "Enable All Tasks History." So when you finally go looking for what happened last Tuesday, the History tab is empty. This isn't a failure mode on its own, but it's why the others stay invisible.

The common thread: every one of these is invisible from the machine's own point of view, or visible only in a place nobody watches. You need a check that lives somewhere else and notices when the task didn't report in.

The heartbeat pattern

A heartbeat monitor inverts the check. Instead of CronAlert reaching out to your server, your task reaches out to CronAlert when it finishes successfully, by making a single HTTP request to a unique URL. You tell the monitor how often to expect that request. If the task is supposed to run hourly and no ping arrives within two hours, you get an alert, and it fires whether the cause was a failed script, a disabled task, a skipped condition, an expired password, or a server that's been off since Tuesday. Nothing needs to be installed, and the server doesn't need to accept inbound connections, which matters for machines behind a corporate firewall.

Create a heartbeat monitor in CronAlert, set the expected interval to the task's schedule, and copy its ping URL. It looks like https://cronalert.com/api/heartbeat/<token> and accepts GET or POST. Heartbeat monitoring is on the Pro plan at $5 per month.

PowerShell: ping only on verified success

The wrapper below is the whole point of this article. It makes errors terminating, checks the exit code of the program it ran, verifies the output exists, and only then sends the heartbeat. If anything fails, it exits 1 so Task Scheduler's Last Run Result is accurate too.

# C:\Jobs\Nightly-Backup.ps1
$ErrorActionPreference = 'Stop'
$Heartbeat = 'https://cronalert.com/api/heartbeat/<token>'
$Target    = "D:\Backups\inventory-$(Get-Date -Format yyyyMMdd).bak"

try {
    & 'C:\Program Files\Backup\backup.exe' --database inventory --out $Target
    if ($LASTEXITCODE -ne 0) { throw "backup.exe exited with $LASTEXITCODE" }

    # Verify the result, not just the exit code
    $file = Get-Item $Target
    if ($file.Length -lt 1MB) { throw "backup file is only $($file.Length) bytes" }

    Invoke-RestMethod -Uri $Heartbeat -Method Post -TimeoutSec 10 | Out-Null
    exit 0
}
catch {
    Write-Error $_
    exit 1
}

Three details matter here. & with an external program does not throw on a non-zero exit, so the $LASTEXITCODE check is required even with Stop set. The verification step catches the class of failure where the program returns 0 but produced garbage. And exit 1 in the catch block is what makes Task Scheduler's own column truthful; without it, PowerShell exits 0 after an unhandled error in many configurations.

For the task's action, use powershell.exe (or pwsh.exe) as the program and put the flags in Arguments:

-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Jobs\Nightly-Backup.ps1"

-NoProfile keeps a broken profile script from taking the task down with it, and -ExecutionPolicy Bypass avoids the run failing after a policy change. Set "Start in" to the script's folder; a blank Start-in field is the usual cause of 0x8007010B ("The directory name is invalid").

Batch files: use curl.exe

Windows 10 (1803 and later) and Windows Server 2019 and later ship a real curl.exe. Check the exit code explicitly, because a batch file's exit status is whatever the last command returned:

@echo off
"C:\Program Files\Backup\backup.exe" --database inventory
if errorlevel 1 exit /b 1
curl.exe -fsS -m 10 -X POST "https://cronalert.com/api/heartbeat/<token>" >NUL
exit /b 0

Write curl.exe with the extension. In Windows PowerShell 5.1, curl without it is an alias for Invoke-WebRequest, which takes different flags and will error on -fsS.

Never put the ping in a second action

Task Scheduler lets a task have multiple actions run in sequence, and it's tempting to add "ping heartbeat" as action two. Don't. The scheduler runs the next action regardless of the previous one's exit code, so the heartbeat fires on failure too, and you're back to a green dashboard over a broken job. The same applies to backup.exe & curl.exe ... in a single command line. The ping has to be behind code that checked the result.

Task settings that stop the silent skips

The heartbeat will catch all of the failure modes above, but several of them are also worth preventing at the source, on the task's General, Conditions, and Settings tabs:

  • General: "Run whether user is logged on or not" for anything on a server. Use a dedicated service account with a non-expiring password, or better, a group Managed Service Account so there is no password to expire. Check "Run with highest privileges" if the job needs it; a task that works from your admin console and fails as a scheduled task is usually an elevation problem.
  • Conditions: uncheck "Start the task only if the computer is on AC power" for anything that must run. Check "Wake the computer to run this task" if the machine sleeps.
  • Settings: check "Run task as soon as possible after a scheduled start is missed." Set "Stop the task if it runs longer than" to a value that makes sense for the job (an hour for a backup that takes ten minutes), so a hang doesn't block tomorrow's run. Leave "Do not start a new instance" for jobs that must not overlap; the heartbeat will tell you when a hang starts eating runs.
  • Triggers: if the server's time zone differs from the people who care about the schedule, check "Synchronize across time zones" on the trigger. Otherwise the run shifts an hour twice a year with daylight saving.

Turn history on, and know where to look

Enable history once per machine. In the Task Scheduler console, right-click "Task Scheduler (Local)" and choose "Enable All Tasks History," or from an elevated prompt:

wevtutil set-log Microsoft-Windows-TaskScheduler/Operational /enabled:true

The events you'll actually use are 100 (task started), 102 (task completed), 103 (action failed to start), 201 (action completed, includes the exit code), 322 (launch request ignored because an instance is already running), and 329 (task stopped because it exceeded the time limit). When a heartbeat alert fires, a filtered view on these for the task's name tells you within seconds whether the scheduler tried and failed or never tried.

For a quick look without the console, schtasks prints everything about a task, including Status, Last Run Result, Next Run Time, and the logon mode:

schtasks /Query /TN "Nightly Backup" /V /FO LIST

A quick-reference for the Last Run Result codes you'll see most: 0x0 success; 0x1 generic failure (usually a script error or wrong path); 0x41301 currently running; 0x41303 has not yet run; 0x8007052E logon failure (stored password); 0x8007010B Start-in directory invalid; 0x800710E0 the task was refused, most often because the "run only when logged on" condition wasn't met.

A canary for the scheduler itself

Everything above is per task. There's also a system-level failure: the Task Scheduler service stopped, the machine is off, the clock is wrong by a large margin, or Windows Update is stuck in a reboot loop. One cheap canary catches all of it. Create a task that runs every 15 minutes with a single action, and give it its own heartbeat monitor with a 15-minute interval:

curl.exe -fsS -m 10 "https://cronalert.com/api/heartbeat/<canary-token>"

If the canary goes quiet, the problem is the machine or the scheduler, not any individual job, and you know it within half an hour instead of discovering it when the first nightly heartbeat misses. Set the canary's task to "Run whether user is logged on or not," with no power or network conditions, so it only ever fails for real reasons. If the machine also runs anything with an HTTP surface, an ordinary uptime monitor on that is a good second signal, and the two together tell you whether the host is down or just the scheduler.

How the interval and the alert timing work

CronAlert's heartbeat monitor alerts when no ping has arrived within twice the expected interval. A nightly task set to a 24-hour interval alerts after 48 hours of silence; a 15-minute canary alerts after 30 minutes. That built-in grace absorbs a run that finishes late without waking anyone, at the cost of a slower alert for a nightly job. If a nightly task's window is tight, consider making it hourly with an early-exit when there's nothing to do, so a miss is caught the same morning rather than the next night. For a longer discussion of choosing intervals, see batch job monitoring; for backups specifically, including the restore-test heartbeat, see monitoring scheduled database backups.

Frequently asked questions

Why does Task Scheduler say success when my script failed?

Last Run Result is the launched program's exit code, and PowerShell and batch files return 0 in most failure cases unless you make them do otherwise. Use $ErrorActionPreference = 'Stop', check $LASTEXITCODE, and exit 1 on any failure.

Can't I just check the History tab?

It's disabled by default, and it can't record runs the scheduler never attempted: disabled tasks, skipped conditions, missed starts, or a machine that was off. A heartbeat monitor outside the machine catches those.

Does the server need inbound access for this?

No. The task makes one outbound HTTPS request. Nothing is installed and no ports are opened.

What about tasks that run every minute?

The pattern works, but the outbound request adds a second or so per run and a lot of pings. Consider a "headroom" approach: ping every fifth run, with a five-minute expected interval, or move the check to a canary that verifies the job's output on its own schedule.

Does this work for Windows Services too?

For a long-running service, the equivalent is a periodic ping from inside the service's main loop, or an HTTP health endpoint the service exposes and CronAlert checks with an ordinary monitor. The background worker guide covers both.

Green column, real backup

A task that's been "completing successfully" for two years has earned no trust; it may have been doing nothing for eighteen months. Ten minutes of work fixes that: a wrapper that only pings after it has checked the result, task settings that don't skip runs quietly, history turned on, and a canary for the scheduler. After that, the first thing you'll hear about a broken nightly job is an alert the next morning, not a restore request that can't be fulfilled. Create a heartbeat monitor and wire in the wrapper. Related reading: cron job heartbeat monitoring, systemd timers, ASP.NET Core monitoring for the web tier on the same servers, and monitoring internal tools.