Azure Functions Timer Trigger: NCRONTAB, Missed Runs, and Alerts
Updated
Contents
An Azure Functions timer trigger runs a function on an NCRONTAB schedule: six fields with seconds first, {second} {minute} {hour} {day} {month} {day-of-week}, evaluated in UTC unless the app sets WEBSITE_TIME_ZONE. 0 */5 * * * * runs every five minutes and 0 30 9 * * 1-5 runs at 09:30 on weekdays. Only one instance runs a given timer, even when the app scales out, because the trigger holds a blob lease in the app's storage account. If the app is down when a run is due, the trigger makes up one run when it starts again, or none, and nothing alerts you either way.
This guide covers Functions runtime 4.x and the .NET isolated worker model (Microsoft.Azure.Functions.Worker.Extensions.Timer 4.3.1, current as of October 24, 2026). The trigger runs in the Functions host; source links below point to its code in azure-webjobs-sdk-extensions.
Isolated Worker or In-Process, and the Plans That Run Timers#
Support for the in-process model ends on November 10, 2026. The in-process model runs .NET 8 at most, and Flex Consumption doesn't support it at all (model comparison), so every example here uses the isolated worker model. Migrating a timer function mostly means adding the Microsoft.Azure.Functions.Worker.Extensions.Timer package and replacing [FunctionName] with [Function].
Plan details from Microsoft's hosting comparison, checked October 24, 2026:
| Plan | OS | functionTimeout default / max |
WEBSITE_TIME_ZONE |
Notes |
|---|---|---|---|---|
| Flex Consumption | Linux | 30 min / unbounded | Not supported | Recommended serverless plan; no slots |
| Consumption (legacy) | Windows; Linux retires September 30, 2028 | 5 min / 10 min | Windows only | The 10-minute ceiling caps long jobs |
| Premium | Windows, Linux | 30 min / unbounded | Supported | Always-ready instances |
| Dedicated (App Service) | Windows, Linux | 30 min / unbounded | Supported | Needs Always On, or the runtime idles |
"Unbounded" still has edges: a running execution gets 60 minutes during scale-in on Flex Consumption and Premium, and 10 minutes during platform updates.
NCRONTAB Syntax#
Functions parses schedules with the NCrontab library. The six fields are second (0-59), minute, hour, day of month, month (1-12 or Jan-Dec), and day of week (0-6 with Sunday as 0, or Sun-Sat):
| NCRONTAB | Runs |
|---|---|
0 */5 * * * * |
Every five minutes, at second 0 |
0 0 */6 * * * |
00:00, 06:00, 12:00, and 18:00 |
0 0 2 * * * |
Daily at 02:00 |
0 30 9 * * 1-5 |
09:30, Monday through Friday |
0 0 9 1-7 * Mon |
09:00 on the first Monday of the month |
*/30 * * * * * |
Every 30 seconds |
*, ,, -, and / work as in standard cron, covered in cron expression syntax, and names are case-insensitive English (timer trigger reference). Two rules differ from Linux cron:
- Five fields are accepted too, and they mean something else. The runtime reads an expression with exactly five fields as minute-first cron and anything else as including seconds (CronSchedule.cs). Dropping one
*from0 */5 * * * *gives0 */5 * * *: minute 0 of every fifth hour, not every five minutes, and no error. - Both day fields must match. NCrontab finds a date matching the day of month, then checks the day of week (CrontabSchedule.cs), so
0 0 9 1-7 * Monis the first Monday. Linux cron runs when either field matches.
TimeSpan Schedules#
The schedule can also be a TimeSpan string such as 01:00:00, which Microsoft documents only for apps that run on an App Service plan (TimeSpan). It sets the interval between invocations rather than clock times, and when a run takes longer than the interval, the next one starts immediately. On Consumption and Flex Consumption, use NCRONTAB.
Schedules in App Settings#
Wrap an app setting name in percent signs to keep the expression out of code: [TimerTrigger("%NightlyCleanupSchedule%")], with an app setting NightlyCleanupSchedule set to 0 0 2 * * *. If the setting doesn't exist, the host throws '%NightlyCleanupSchedule%' does not resolve to a value and the timer doesn't start (NameResolverExtensions.cs). Changing an app setting restarts the app (app settings).
Time Zones and Daylight Saving Time#
Schedules run in UTC by default. For local time, add an app setting named WEBSITE_TIME_ZONE (NCRONTAB time zones):
- Windows, any plan: a Windows time zone name from
tzutil.exe /L, such asEastern Standard Time. - Linux on Premium or Dedicated: a tz database name, such as
America/New_York. - Linux on Flex Consumption or Consumption: not supported. Microsoft warns that setting
WEBSITE_TIME_ZONEorTZthere can cause SSL-related issues and stop your app's metrics.
With the setting in place, the schedule follows daylight saving time: 0 0 10 * * * on America/New_York fires at 10:00 local all year. On the night clocks go back, the extension skips the second occurrence of a repeated time, so a job at 01:30 runs once (CronSchedule.cs). The reference doesn't say what a job scheduled inside the skipped spring hour does, so keep daily jobs out of the hour around the change, or leave the app on UTC.
One Run at a Time: The Singleton Lease#
The timer's listener is a singleton (TimerListener.cs): before an instance runs the schedule, it must hold a blob lease under locks/<host-id>/ in the azure-webjobs-hosts container of the AzureWebJobsStorage account, and other instances wait (BlobLeaseDistributedLockManager.cs). That's how "only a single instance of a timer-triggered function is run across all instances" (scale-out). It's also why timer triggers depend on blob storage everywhere except local Core Tools, and why an identity-based AzureWebJobsStorage connection needs the Storage Blob Data Owner role (connections).
The lease is keyed by host ID, and that's where it breaks:
- Two apps, one storage account, one host ID: only one timer runs (sharing storage). Outside Flex Consumption, the host ID defaults to the app name truncated to 32 characters, so
contoso-billing-reconciliation-jobs-prodandcontoso-billing-reconciliation-jobs-testcollide. Runtime 4.x detects that case, logsAZFD0004, and stops the host (host ID considerations), but it checks only truncated default IDs (ScriptHostIdProvider.cs): two apps given the same explicitAzureFunctionsWebHost__hostidsilently share one timer. Flex Consumption generates unique host IDs. - A staging slot runs its own copy. With its own host ID it holds its own lease, so its timer fires alongside production's. Disable the function there with an
AzureWebJobs.<FunctionName>.Disabledapp setting marked as a deployment slot setting (disable in a slot).
Missed Runs: UseMonitor, ScheduleStatus, and IsPastDue#
UseMonitor defaults to true for schedules that recur once a minute or less often, and to false for schedules that fire more than once a minute (attributes). With it on, the host stores a status blob at timers/<host-id>/<function>/status in azure-webjobs-hosts (StorageScheduleMonitor.cs) and passes it to the function in TimerInfo:
| Property | Meaning |
|---|---|
ScheduleStatus.Last |
The last recorded run |
ScheduleStatus.Next |
The next expected occurrence |
ScheduleStatus.LastUpdated |
When the record was written; used to recompute Next after a restart |
IsPastDue |
This invocation is later than scheduled |
In the isolated worker, ScheduleStatus is null when UseMonitor is off (TimerInfo.cs). When the listener starts, it compares the stored Next with the clock (TimerListener.cs, ScheduleMonitor.cs):
- If
Nexthas passed, it runs the function once, immediately, withIsPastDueset. That's one catch-up run however many occurrences were missed: a daily job whose app was down from Friday night to Monday morning runs once on Monday. - If the schedule expression changed, the stored status is reset and there is no catch-up run.
- With
UseMonitoroff, missed occurrences are skipped without a trace.
The status is written only after an invocation returns. If the host process dies mid-run, that occurrence stays unrecorded, and the next listener start treats it as past due and runs it again. Together with slots and retries, that's why Microsoft recommends designing functions for identical input: a timer job should be safe to run twice.
RunOnStartup#
RunOnStartup = true invokes the function whenever the runtime starts: when the app wakes from idle, restarts after a change, or scales out. Microsoft says it "should rarely if ever be set to true, especially in production," because it runs code at unpredictable times, and on the Consumption plan the extra invocations on every scale event can raise costs significantly (attributes and caution). To run a job once after a deployment, invoke it manually instead.
Timeouts, Retries, and Overlapping Runs#
Timeouts. functionTimeout in host.json (for example "functionTimeout": "00:45:00") caps each execution; the defaults are in the plan table. When an execution exceeds it, the runtime logs a timeout error and restarts the language worker process (timeout duration), ending any work in flight.
Retries. By default a failed timer run isn't retried; the next attempt is the next occurrence (retry behavior). Timer triggers do support function-level retry policies, [FixedDelayRetry] and [ExponentialBackoffRetry], in the isolated worker with Microsoft.Azure.Functions.Worker.Extensions.Timer 4.2.0 or later. The retry count lives in the instance's memory and a timer retry doesn't resume on another instance, so you can get fewer retries than you asked for (retry policies).
Overlap. A timer never overlaps itself within one app: it "will not trigger again if there is an outstanding invocation still running" (scale-out). The listener computes the next occurrence from the time a run started and arms the timer only after the run returns (TimerListener.cs). An every-five-minutes job that runs from 10:00 to 10:12 runs again at 10:12, immediately and without IsPastDue, then at 10:15. The 10:05 and 10:10 occurrences collapse into that one late run.
Why an Azure Functions Timer Trigger Isn't Firing#
| Cause | What to check |
|---|---|
| The app is stopped or idle | App state; on a Dedicated plan, Always On, which is set per slot |
| Triggers weren't synced after deploying | External package URL, Local Git, and FTPS deployments need a manual trigger sync |
| The function is disabled | AzureWebJobs.<FunctionName>.Disabled set to true; one added in staging without Deployment slot setting moves to production in a swap |
| Host storage is unreachable | The AzureWebJobsStorage connection, storage network rules, rotated keys, the identity's role |
| Another app holds the lease | Shared storage and a matching host ID; AZFD0004 in the logs |
| The schedule is in another zone | UTC by default; no WEBSITE_TIME_ZONE on Flex Consumption or Linux Consumption |
| Five fields where you meant six | 0 */5 * * * runs at minute 0 of every fifth hour |
| The schedule doesn't load | An invalid expression (the portal's Test/Run returns a 404) or an unresolved %Setting%; Application Insights has the error |
| It ran, but you can't see it | Sampling, or a Host.Results log level above Information, drops rows from requests (log categories) |
Alerting When a Timer Run Fails or Never Starts#
Application Insights records each invocation in the requests table, so an alert on failed requests catches runs that throw or time out. A run that never starts writes nothing for that alert to fire on. A log search alert can fire when a function's request count drops to zero, but each query has to encode its function's schedule, and Microsoft notes that log search alerts "work best when you're trying to detect specific data in the logs, as opposed to when you're trying to detect a lack of data" (alert types).
The pattern built for absence is a dead man's switch: the function checks in on every run, and a service outside Azure that knows the schedule alerts when a check-in is late.
Add CronRadar to an Isolated Worker Timer Function#
Azure Functions has no CronRadar auto-discovery; each timer function reports through the base CronRadar .NET SDK. NuGet installs CronRadar 1.0.13 (checked October 24, 2026):
dotnet add package CronRadarCreate one CronRadar application per environment and put its API key in an app setting named CRONRADAR_API_KEY; app settings reach your code as environment variables, where the SDK reads it. A Key Vault reference keeps the key out of the app's configuration, as long as the app's managed identity can read the secret:
az functionapp config appsettings set \
--name contoso-maintenance --resource-group contoso-prod \
--settings "[email protected](SecretUri=https://contoso-prod-kv.vault.azure.net/secrets/cronradar-api-key)"For local runs, put a development application's key in local.settings.json under Values. Then wrap the job:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace Contoso.Maintenance;
// Your service that does the cleanup; register an implementation in Program.cs.
public interface ISessionCleanup
{
Task DeleteExpiredAsync(CancellationToken cancellationToken);
}
public class NightlyCleanup(ISessionCleanup cleanup, ILogger<NightlyCleanup> logger)
{
[Function(nameof(NightlyCleanup))]
public async Task Run(
[TimerTrigger("0 0 2 * * *")] TimerInfo timer, // 02:00:00 UTC every day
CancellationToken cancellationToken)
{
if (timer.IsPastDue)
{
logger.LogWarning("Catch-up run for a missed occurrence. Last recorded run: {Last}",
timer.ScheduleStatus?.Last);
}
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("nightly-cleanup", async () =>
{
// Give up well before functionTimeout so a hang becomes a reported failure.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromMinutes(20));
await cleanup.DeleteExpiredAsync(deadline.Token);
}, schedule: "0 2 * * *");
}
}The wrapper sends start before the work and complete after it returns. If the work throws, it sends fail with the exception message and rethrows the original exception, so Functions still records the failed invocation and a retry policy still sees it. Each check-in times out after 5 seconds and never throws: if cron.life is unreachable, the run takes up to about ten seconds longer and its result doesn't change. The schedule argument creates the monitor on the first run and recreates it if it's ever deleted.
The 20-minute deadline matters because of how CronRadar detects stuck runs. Every check-in, start included, moves the expected time to the next scheduled occurrence; if that time plus the grace period passes while the last event is a start, the monitor goes critical as stuck. A run cut off by functionTimeout may never send its fail, because the worker process restarts, and the next night's start resets the window, so a job that timed out every night might never be flagged as stuck. A deadline shorter than functionTimeout turns the hang into a fail that alerts immediately, as long as the work honors the token. On Consumption, with its 5-minute default, set it lower. A host shutdown cancels the same token and is reported as a failure too.
With a retry policy, each attempt is a separate invocation, so the first failed attempt alerts even if a retry succeeds; the retry's complete then records the recovery.
Convert the Schedule for CronRadar#
CronRadar reads five-field cron in UTC. Drop the seconds field and convert local times:
| Functions schedule | CronRadar schedule |
|---|---|
0 0 2 * * * |
0 2 * * * |
0 0 9 1-7 * Mon |
0 9 1-7 * 1; CronRadar also requires both day fields |
30 0 2 * * * (02:00:30) |
0 2 * * *; the grace period absorbs the 30 seconds |
0 0 10 * * * with WEBSITE_TIME_ZONE=America/New_York |
0 14 * * * with a longer grace period |
*/30 * * * * * |
None: five fields can't express more than one run a minute |
01:00:00 (TimeSpan) |
None: interval schedules aren't accepted |
A five-field Functions schedule passes through unchanged if the app runs in UTC. Work that must run more than once a minute fits better in a long-running process; see scheduled tasks with BackgroundService.
For a WEBSITE_TIME_ZONE schedule that follows daylight saving time, use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration. 10:00 in New York is 14:00 UTC in summer and 15:00 UTC in winter, so send 0 14 * * *. WrapAsync takes no grace period, and DefaultGracePeriodSeconds has no effect here: the start check-in carries only the schedule, and CronRadar creates the monitor with its 90-second default. Set the grace period in the CronRadar dashboard (Grace Period, in seconds), either by creating the monitor before the first run or by editing the one the first run creates.
In winter the monitor then shows warning for the hour before each run. Leaving the app on UTC avoids the whole problem.
Run the function once from the portal, confirm nightly-cleanup shows a success result in the right CronRadar application, then let one scheduled run arrive. The .NET monitoring overview covers other .NET schedulers.
Frequently Asked Questions#
What cron format does an Azure Functions timer trigger use?#
NCRONTAB, with six fields and seconds first, so daily at 02:00 is 0 0 2 * * *. A five-field expression is also accepted and read as standard minute-first cron. On an App Service plan, the schedule can instead be a TimeSpan interval such as 01:00:00.
How do I set the time zone for an Azure Functions timer?#
Add a WEBSITE_TIME_ZONE app setting: a Windows time zone name on Windows, or a tz database name such as America/New_York on Linux Premium and Dedicated. Flex Consumption and Linux Consumption don't support it, so write those schedules in UTC.
Why didn't my Azure Functions timer trigger run?#
Usually an idle Dedicated app without Always On, triggers not synced after deployment, a disabled function, unreachable host storage, a host ID collision, or a schedule in an unexpected zone. If the host was down at the scheduled time, UseMonitor makes up one run at startup; without it, the run is skipped.
Can a timer-triggered function run twice at the same time?#
Not within one function app: a blob lease keeps the timer on one instance, and it won't fire while the previous run is still going. Duplicates come from outside that lease: a staging slot, two apps with different host IDs running the same code, RunOnStartup, or a past-due rerun after the host died mid-run.
How do I get alerted when a timer function doesn't run?#
Have the function check in with a monitor outside Azure on every run. With CronRadar, wrap the job in CronRadar.CronRadar.WrapAsync with the five-field UTC schedule: a missing check-in raises an alert after the grace period, and an exception raises one immediately.
Put this guide to work
Connect your .NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar 1.0.13. Scheduled heartbeat reporting is available; execution IDs, outcome values, and CRONRADAR_BASE_URL support in the repository reference require the pending 1.0.14 release.