Timer triggers are the cron of Azure Functions: an NCRONTAB expression on a function and the host runs it on schedule. It's a good scheduler, and like every scheduler covered in this series — EventBridge, Cloud Scheduler, Kubernetes CronJobs — its failure modes are mostly not errors. A function that throws produces a red row in Application Insights. A function that never runs produces nothing, and nothing is very hard to alert on from inside the platform that produced it.

This post covers the ways a timer-triggered function stops running silently, what Azure's own monitoring can see, and the pattern that catches all of it: a heartbeat ping at the end of every run, plus a canary timer in the same Function App that proves the host is scheduling at all. Examples use the Node v4 and Python v2 programming models, and everything here applies to isolated .NET, Java, and PowerShell functions unchanged.

Where "the app is healthy" and "the job ran" diverge

Azure tells you about the Function App: it's running, it's scaled, its HTTP endpoints answer. The timer function is a component inside that app whose only job is to execute at a moment, and the app can be perfectly healthy while that moment passes with nothing happening. Every failure below has this shape. The host is up. The dashboard is green. The function didn't run.

The six silent failures

1. The function is disabled

Every function in the app can be disabled individually: the Disable button in the portal, or an app setting named AzureWebJobs.YourFunctionName.Disabled set to true. Disabled is a valid, deliberate state — someone turned off the nightly export during an incident, or disabled the function in a slot before a swap and the setting travelled with it. The host loads it, notes it as disabled, and moves on. No execution, no failure, no alert, and the function still appears in the list with a small grey badge that nobody's eyes land on.

2. The host went idle (Dedicated plans)

On a Consumption or Premium plan, Azure's scale controller keeps track of timer schedules and makes sure a host is awake when one is due. On a Dedicated (App Service) plan nothing does that for you: the host is unloaded after a period without inbound HTTP traffic, and a timer cannot wake it. The fix is the Always On setting, which Azure's documentation tells you to enable for timers on Dedicated plans and which is off by default on many tiers. The symptom when it's missed is a timer that runs fine for a while — because deploys and portal visits keep the host warm — and then stops on the first quiet weekend.

3. The storage account behind the timer changed

The timer trigger depends on the app's AzureWebJobsStorage account for two things: a blob lease that makes the function a singleton across instances (so scaling to five instances doesn't run it five times), and a small status blob recording the last and next scheduled times. If that account is deleted during a cleanup, its access key is rotated without updating the connection string, it's moved behind a firewall or private endpoint the app can't reach, or the app was switched to identity-based connections and the managed identity is missing a blob data role — the timer listener fails to start. The host logs something like "The listener for function 'Functions.Nightly' was unable to start" and carries on serving HTTP functions normally. From outside, the app is healthy. The timer will never fire again until someone reads that log line.

4. The run was killed by the timeout

On the Consumption plan a function execution is limited by functionTimeout in host.json: five minutes by default, ten at most (Premium and Dedicated allow longer or unbounded). A timer function that grew with the data — the export that takes six minutes now — is terminated mid-run. That is logged as a failure, but timer triggers don't retry by default, and a run that was killed after writing half its output has no hook in which to tell anyone it was killed. If your "did it run?" signal is a log line written at the end of the function, the timeout takes the signal with it. The same applies to an unhandled exception: one failed row in Application Insights, no retry, and the next attempt is the next scheduled time.

5. The schedule doesn't mean what you think

NCRONTAB expressions have six fields, with seconds first: {second} {minute} {hour} {day} {month} {day-of-week}. The tooling accepts five-field expressions too and interprets them as starting at minutes, which is exactly what makes copy-paste dangerous — a standard cron 0 2 * * * (daily at 02:00) pasted into a six-field mindset as 0 0 2 * * * is still daily at 02:00, but 0 2 * * * *, one field short of intent, is hourly at two minutes past. Times are UTC unless you set the WEBSITE_TIME_ZONE app setting, and that setting's availability depends on your plan and OS (it isn't honored on Linux Consumption at the time of writing), so the function you think runs at 6 AM local runs at 6 AM UTC and the function you think runs daily runs hourly. Both look like success in every log. The heartbeat guide has the general "wrong schedule looks like success" argument; here it's sharpened by a field you may not know exists.

6. It ran twice: slots and RunOnStartup

The inverse failure, as with every scheduler. Deployment slots each run their own host, and a timer function in the staging slot fires on the same schedule as production — so the nightly invoice job sends every invoice twice, once from each slot, and both slots log success. Disable the function in the non-production slot with AzureWebJobs.YourFunctionName.Disabled=true marked as a slot setting, so it stays put during a swap. Separately, runOnStartup: true executes the function every time the host starts — every deploy, every restart, every scale-out instance — which is convenient in development and a duplicate-side-effect generator in production. Leave it false.

What Application Insights gets you (and where it stops)

Application Insights records each execution as a request whose operation_Name is the function name, and Azure Monitor can run a log-search alert on a query like requests where operation_Name is "Nightly" and success is true, count over the last 25 hours, alert if zero. That's a legitimate silence detector and it does catch failures 1 through 3, because a function that doesn't run produces no request row. Three caveats keep it from being the only line:

  • It shares fate with the telemetry pipeline. A missing or wrong connection string, sampling under load, or a host that can't reach the ingestion endpoint produces the same "no rows" as a function that didn't run — and also, depending on configuration, no alert.
  • Latency. Ingestion plus the alert's evaluation frequency means minutes to tens of minutes between the missed run and the notification, and log alerts are priced per rule, so teams tend to make the windows generous.
  • It can't see intent. A run that completed in three seconds because the upstream query returned zero rows is a successful request. Failure 5's hourly-instead-of-daily job produces more rows than expected, which no "count is zero" rule notices.

Keep the log alert if you have it. Add the external signal that doesn't share fate with anything in the subscription.

The heartbeat pattern

Create a heartbeat monitor in CronAlert with the expected interval set to the function's schedule. CronAlert marks the monitor down when no ping has arrived within twice that interval, so a daily job alerts after roughly 48 hours of silence and a five-minute job after ten minutes. Then ping the URL as the last line of the function, after the real work has succeeded:

// Node.js v4 programming model — src/functions/nightlyExport.js
const { app } = require("@azure/functions");

app.timer("nightlyExport", {
  schedule: "0 0 2 * * *",   // six fields: 02:00 UTC daily
  runOnStartup: false,
  handler: async (timer, context) => {
    if (timer.isPastDue) context.log("running late — catch-up after a restart");

    await runExport();        // throw on any failure; nothing below runs

    const res = await fetch(process.env.EXPORT_HEARTBEAT_URL, { method: "POST" });
    if (!res.ok) context.warn(`heartbeat ping failed: ${res.status}`);
  },
});
# Python v2 programming model — function_app.py
import os, urllib.request
import azure.functions as func

app = func.FunctionApp()

@app.timer_trigger(schedule="0 0 2 * * *", arg_name="timer",
                   run_on_startup=False, use_monitor=True)
def nightly_export(timer: func.TimerRequest) -> None:
    run_export()  # raise on failure so the ping below is skipped

    urllib.request.urlopen(os.environ["EXPORT_HEARTBEAT_URL"], timeout=10)

Walk the six failures. Disabled: no ping, alert. Host idle: no ping, alert. Storage listener failed: no ping, alert. Killed by timeout or unhandled exception: the ping is after the work, so no ping, alert. Hourly-instead-of-daily: a burst of pings where there should be one — visible in the monitor's history, and the reason to glance at a new heartbeat's first day of pings before trusting it. Slots firing twice: two pings per night on a daily monitor, same story. Store the heartbeat URL as an app setting, not in code, so staging can point at nothing (or at its own monitor) instead of masking production's silence with its pings.

Two Azure-specific notes. If the app is VNet-integrated with outbound traffic routed through the network, the ping needs a route to the public internet like any other egress — a heartbeat that never arrives because of an NSG rule looks identical to a function that never ran, so send one manual ping when you set it up. And on the Consumption plan, keep the heartbeat call short and inside the timeout budget; it's one fast request and shouldn't be the thing that pushes a five-minute job over.

A canary for the host itself

Per-job heartbeats tell you a specific job stopped. They don't tell you whether it stopped because of the job or because of the platform — and failures 2 and 3 are platform failures that take every timer in the app down together. A canary timer settles it: one trivial function in the same Function App, every five minutes, that does nothing but ping a heartbeat.

app.timer("canaryHeartbeat", {
  schedule: "0 */5 * * * *",   // every 5 minutes, on the minute
  runOnStartup: false,
  handler: async () => {
    await fetch(process.env.CANARY_HEARTBEAT_URL, { method: "POST" });
  },
});

Heartbeat monitor at five minutes (overdue at ten). Now the two signals are diagnostic: silent nightly job, live canary means look at that function — disabled, timed out, wrong schedule. Silent nightly job, silent canary means the host isn't scheduling anything — Always On, the storage account, a stopped app, a bad deploy — and you knew within ten minutes rather than at the next morning's missing report. The canary shares the storage account with the real timers, which is the point: when the lease blob becomes unreachable, the canary dies first and loudest.

Testing it

  • Disable the canary in the portal. Wait ten minutes. Confirm the alert arrives. Re-enable it. This is the whole chain end to end and it costs nothing.
  • Break the storage connection in a non-production app — point AzureWebJobsStorage at a nonexistent account and restart. Watch HTTP functions keep working while every timer goes quiet, then read the host log line you'd otherwise have found a week later.
  • Deploy a slot with the timer enabled once, on purpose, and watch the double ping. It's the fastest way to convince a team the slot setting matters.
  • A heartbeat you've never seen fail is a hope, not a system.

Set it up in ten minutes

  • Create a CronAlert account — heartbeat monitors are on Pro at $5/mo (100 monitors, 1-minute checks). Any HTTP-triggered function or health endpoint in the app fits on the free plan as a regular uptime check.
  • Add a heartbeat monitor per timer function whose silence would cost you, with the expected interval set to its schedule; ping from the last line of each.
  • Add the five-minute canary timer to the same app; heartbeat monitor at five minutes.
  • Confirm Always On if you're on a Dedicated plan, runOnStartup: false everywhere, and the disabled setting is slot-sticky on staging.
  • Disable the canary once to prove the alert arrives.

Frequently asked questions

Does Azure alert on a timer that stopped?

Not by default. A log-search alert on zero successful executions works but shares fate with Application Insights and adds latency. An external heartbeat is independent of the subscription.

Why did my timer stop on an App Service plan?

Always On is off and the host was unloaded. Timers can't wake a Dedicated-plan host; only inbound HTTP can. Consumption and Premium handle this through the scale controller.

Why does it run twice?

Staging slot firing alongside production, or runOnStartup: true executing on every host start. Slot-sticky disabled setting for the first, false for the second.

What does the storage account have to do with timers?

Singleton lease and schedule status live there. If the app can't reach it, the timer listener fails to start while HTTP functions keep working.

Do I need a heartbeat for every timer?

No. The canary plus the handful whose silence would hurt. Fifty heartbeats is alert fatigue, not coverage.

Trust the beat, not the green app

A healthy Function App and a running timer are different facts, and Azure is much better at reporting the first. A ping at the end of every run turns "did it happen?" into a question something outside the subscription answers within one interval, and a canary in the same app tells you whether to look at the job or the host. Related reading: monitoring EventBridge scheduled Lambdas, monitoring Cloud Scheduler and Cloud Run jobs, uptime monitoring for serverless, monitoring ASP.NET Core apps, and the heartbeat guide.