The standard AWS cron job is an EventBridge schedule pointed at a Lambda: rate(1 hour) or cron(0 2 * * ? *), a target, done. It's cheap, it scales to zero, and it has the same problem as every managed scheduler we've written about — GitHub Actions, Vercel, Cloudflare, Kubernetes — plus a few of its own. The platform records that it invoked something. Nothing records that the work got done, and several failure modes produce no error at all.
This guide covers both flavors — classic EventBridge rules and the newer EventBridge Scheduler — because they fail the same way. The fix is the same too: a heartbeat the function sends after the work completes, so that every failure below collapses into one observable signal.
Where "invocation succeeded" and "job ran" diverge
EventBridge invokes Lambda asynchronously. The schedule fires, Lambda accepts the event onto its internal queue, and EventBridge records success at that moment — before your code has run a single line. What happens next is Lambda's business: the function runs, and if it throws, the async retry policy retries (twice by default, with backoff) and then discards the event unless you configured a dead-letter queue or an on-failure destination. Most schedules have neither. So a job that has thrown on every run for three days looks like this: EventBridge dashboard green, Lambda Invocations metric healthy, Errors metric climbing in a graph you're not looking at, no email, no page.
That's the baseline. Now the ways the invocation itself stops happening.
The five silent failures
1. The rule is disabled
Someone disables the rule during an incident to stop a job from making things worse, and it stays disabled. Or infrastructure-as-code does it: enabled: false committed to pause a job "temporarily," a CloudFormation rollback that restores an older template, a CDK refactor that renames the construct and deletes the old schedule. A disabled rule is a valid state — you asked for it — so nothing errors, nothing alarms, and the only trace is a schedule that stopped producing output. This is config drift, and it's the single most common way scheduled Lambdas die.
2. The invoke permission is gone
EventBridge needs a resource-based policy on the Lambda (or, for Scheduler, an IAM role with lambda:InvokeFunction) to call it. Recreate the function, restore it from a different stack, tighten a policy — and EventBridge can no longer invoke the target. It doesn't fail loudly: it increments the rule's FailedInvocations metric and moves on. That metric exists precisely for this, and almost nobody alarms on it, because you have to know it exists to know to.
3. Errors retry, then vanish
The async path above. Two extra details bite: retries mean a partially-completed job runs again, so any non-idempotent work (sending emails, charging cards, appending rows) gets duplicated on failure; and an event that keeps failing is eventually dropped with no record beyond a metric. If a job needs "at most once" semantics, it has to enforce them itself with an idempotency key — the scheduler won't.
4. Concurrency and throttling
Setting reserved concurrency to zero is a popular way to "pause" a function. It works — every invocation is throttled — and async invocations that are throttled retry for up to six hours before being discarded. Un-pause it a day later and the schedule has quietly skipped a day. Account-level concurrency exhaustion (a burst from some other function) produces the same throttled-then-dropped pattern for your cron job, with no failure attributable to the job itself.
5. The schedule expression is wrong
AWS cron is six fields, not five — minutes, hours, day-of-month, month, day-of-week, year — and you must use ? in either day-of-month or day-of-week. Unix habits produce expressions that are either rejected (good) or accepted and mean something other than intended (bad). Classic rules are also UTC-only: "9 AM" is 9 UTC, and a job "scheduled for after the nightly close" runs an hour early or late twice a year. EventBridge Scheduler fixes the timezone problem (it takes a timezone and handles daylight saving), but adds flexible time windows — a job with a 15-minute window can fire anywhere inside it, which matters when you set a grace period below.
What CloudWatch alone gets you (and where it stops)
You can build real alerting inside AWS, and you should do some of it regardless:
- An on-failure destination or DLQ on the function, so failed events go somewhere instead of nowhere. This is a one-line config and it's the first-party answer to failure mode 3.
- An alarm on
Errors> 0 for the function, routed to SNS. Catches throwing jobs. - An alarm on
FailedInvocationsfor the rule. Catches failure mode 2. - An alarm on
Invocations< 1 with treat missing data as breaching, evaluation period matched to the schedule. This is the AWS-native silence detector and it does catch a disabled rule — but it's one alarm per function, the period math gets awkward for anything infrequent, and it lives in the same account and region as the job.
That last point is the structural one. The watcher and the watched share an account, a region, an IAM boundary, and a deploy pipeline. The same CloudFormation rollback that deletes the schedule can delete the alarm; a regional event that stalls Lambda can stall the metrics that would report it. None of this makes CloudWatch alarms bad — it makes them insufficient as the only observer, which is the entire argument for external monitoring, applied to the scheduler.
The heartbeat pattern
One external heartbeat monitor per schedule, expecting a ping at the schedule's cadence plus grace. The function pings after the work, so a run that started but failed never pings, and the missed beat raises the alert. In Node (the Lambda Node.js runtimes ship a global fetch):
export const handler = async (event) => {
await runNightlyReport(event);
// Success signal -- after the work, awaited, unreachable on throw.
await fetch("https://cronalert.com/api/heartbeat/YOUR_TOKEN");
}; And in Python, with nothing outside the standard library:
import urllib.request
def handler(event, context):
run_nightly_report(event)
# Success signal -- after the work, unreachable on exception.
urllib.request.urlopen(
"https://cronalert.com/api/heartbeat/YOUR_TOKEN", timeout=10
) Three placement rules, all load-bearing:
- After the work, not before. A ping at the top of the handler asserts "I started," which is exactly what EventBridge already told you. The point is to assert completion.
- Awaited, and outside any catch that swallows. If you wrap the job in a try/except for logging, re-raise or return early so the ping stays unreachable on failure. A ping that runs anyway turns your monitor into a liar.
- Outbound network required. A Lambda inside a VPC with no NAT gateway or egress path can't reach the heartbeat URL — the ping fails, the monitor alerts, and you've found a real problem (the job probably can't reach anything else either). Check egress before assuming a false positive.
Set the expected interval to the schedule and the grace to cover variance. An hourly job: expect a ping every hour, grace of five to ten minutes. If you're on Scheduler with a flexible time window, add the window — a daily job with a 1-hour window needs at least an hour of grace or you'll alert on runs that are merely late by design. For jobs that run long enough to hit Lambda's 15-minute ceiling, use the start/finish pair from our batch-job guide to distinguish "never started" from "timed out mid-run."
Now walk back through the five failures: disabled rule, missing permission, error-retry-drop, throttled-then-discarded, wrong cron expression. Every one of them ends the same way — the pings stop — and the heartbeat monitor notices within one interval plus grace, from outside the account, without knowing or caring which of the five it was. That's the trade: you lose the diagnosis (CloudWatch still has it) and gain a detector that can't be taken down by the thing it's detecting.
A canary for the scheduler itself
If you run many schedules in one account, add one more: a rate(5 minutes) schedule whose Lambda does nothing but ping a heartbeat. It costs effectively nothing (well inside Lambda's permanent free tier) and it answers a question the per-job heartbeats can't: when several jobs go quiet at once, is it them, or is it the EventBridge-to-Lambda path in this region? A silent canary means look at the platform; a live canary next to a silent job means look at the job. The same trick works for Celery Beat and Kubernetes CronJobs, and for the same reason.
Testing it
- The failure path. Deploy a version that throws before the ping, wait one interval plus grace, confirm the alert arrives. Then fix it and confirm the incident resolves on the next ping. A heartbeat you've never seen fail is a hope, not a system.
- The drift path. Disable the rule in the console. Nothing in AWS will tell you; the heartbeat should, after grace. Re-enable it. Do this once so you believe it.
- The duplicate path. Throw after the side effects but before the ping, and watch the retries run your side effects again. If that's unacceptable, add the idempotency key before you rely on the schedule in production.
Set it up in ten minutes
- Create a CronAlert account — heartbeat monitors are on Pro at $5/mo (100 monitors); HTTP monitors for any API Gateway or function URL the Lambdas serve fit on the free plan.
- One heartbeat monitor per schedule, interval = schedule, grace = runtime variance plus any flexible window.
- Add the awaited ping after the work in each handler; check VPC egress if the function is in a private subnet.
- Add an on-failure destination or DLQ on the function anyway — the heartbeat tells you that it failed, the DLQ keeps the event so you can see why.
- Optional: the 5-minute canary schedule, and CloudWatch alarms on
ErrorsandFailedInvocationsfor in-account diagnosis.
Frequently asked questions
Does AWS alert on a failed scheduled Lambda?
Not by default. Async errors retry twice and are dropped unless a DLQ or destination is configured; metrics exist but alarms don't until you create them; a disabled rule or missing permission produces no error at all.
Can a CloudWatch alarm catch a schedule that stopped?
Yes — Invocations < 1 with missing data treated as breaching. It's per-function, the period math is fiddly for infrequent jobs, and it shares fate with the job. Use it alongside an external heartbeat, not instead of one.
Why doesn't a disabled rule error?
Because disabled is a valid state you asked for. Only silence-based detection catches "technically correct, not what I wanted."
Rules or Scheduler?
Scheduler for new work: timezones with DST handling, one-time schedules, flexible windows. Both fail the same way and both use six-field cron. Add the flexible window to your grace period.
What if the Lambda is in a VPC?
It needs an egress path (NAT gateway or equivalent) to reach the heartbeat URL. If the ping fails for that reason, the job probably can't reach its other dependencies either — treat it as a finding, not a false positive.
Trust the beat, not the invocation count
EventBridge is a good scheduler. It's just not a monitor, and the metrics it emits describe hand-offs rather than outcomes. Put the ping after the work, set the grace to cover the schedule's real variance, keep the watcher outside the account — and the five ways a scheduled Lambda dies quietly become one alert that arrives on time. Related reading: monitoring serverless functions, the heartbeat guide, monitoring message queues and dead-letter queues, and monitoring long-running batch jobs.