# Azure Functions Timer Trigger: NCRONTAB, Missed Runs, and Alerts

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/azure-functions-timer-trigger

> How the Azure Functions timer trigger reads NCRONTAB schedules and time zones, why runs get missed or duplicated, and how to get alerted when one never starts.

**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](https://github.com/Azure/azure-webjobs-sdk-extensions/tree/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers).

## Isolated Worker or In-Process, and the Plans That Run Timers

[Support for the in-process model ends on November 10, 2026](https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model). The in-process model runs .NET 8 at most, and Flex Consumption doesn't support it at all ([model comparison](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-in-process-differences)), 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-scale), 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](https://github.com/atifaziz/NCrontab). 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](https://cronradar.com/blog/cron-expression), and names are case-insensitive English ([timer trigger reference](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#ncrontab-expressions)). 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](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/CronSchedule.cs)). Dropping one `*` from `0 */5 * * * *` gives `0 */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](https://github.com/atifaziz/NCrontab/blob/d122d586a0665580f3fde9b8cfc03c5e3abf1909/NCrontab/CrontabSchedule.cs)), so `0 0 9 1-7 * Mon` is 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#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](https://github.com/Azure/azure-webjobs-sdk/blob/b793625b889b8cd1b4cb9d55624a1e72dd5faf7a/src/Microsoft.Azure.WebJobs.Host/NameResolverExtensions.cs)). Changing an app setting restarts the app ([app settings](https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#ncrontab-time-zones)):

- **Windows, any plan:** a Windows time zone name from `tzutil.exe /L`, such as `Eastern Standard Time`.
- **Linux on Premium or Dedicated:** a [tz database](https://www.iana.org/time-zones) name, such as `America/New_York`.
- **Linux on Flex Consumption or Consumption:** not supported. Microsoft warns that setting `WEBSITE_TIME_ZONE` or `TZ` there 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](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/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](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/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](https://github.com/Azure/azure-webjobs-sdk/blob/b793625b889b8cd1b4cb9d55624a1e72dd5faf7a/src/Microsoft.Azure.WebJobs.Host.Storage/Singleton/BlobLeaseDistributedLockManager.cs)). That's how "only a single instance of a timer-triggered function is run across all instances" ([scale-out](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#function-apps-sharing-storage)). Outside Flex Consumption, the host ID defaults to the app name truncated to 32 characters, so `contoso-billing-reconciliation-jobs-prod` and `contoso-billing-reconciliation-jobs-test` collide. Runtime 4.x detects that case, logs `AZFD0004`, and stops the host ([host ID considerations](https://learn.microsoft.com/en-us/azure/azure-functions/storage-considerations#host-id-considerations)), but it checks only truncated default IDs ([ScriptHostIdProvider.cs](https://github.com/Azure/azure-functions-host/blob/30c29d7b32141e3e137dd6339958e978cbf06529/src/WebJobs.Script/Host/ScriptHostIdProvider.cs)): two apps given the same explicit `AzureFunctionsWebHost__hostid` silently 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>.Disabled` app setting marked as a deployment slot setting ([disable in a slot](https://learn.microsoft.com/en-us/azure/azure-functions/disable-function#disable-functions-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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#attributes)). With it on, the host stores a status blob at `timers/<host-id>/<function>/status` in `azure-webjobs-hosts` ([StorageScheduleMonitor.cs](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions.Timers.Storage/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](https://github.com/Azure/azure-functions-dotnet-worker/blob/bbf17dfc9eb805f555e135ca775482427ef70262/extensions/Worker.Extensions.Timer/src/TimerInfo.cs)). When the listener starts, it compares the stored `Next` with the clock ([TimerListener.cs](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/TimerListener.cs), [ScheduleMonitor.cs](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/ScheduleMonitor.cs)):

- **If `Next` has passed,** it runs the function once, immediately, with `IsPastDue` set. 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 `UseMonitor` off,** 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-idempotent): 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#attributes)). To run a job once after a deployment, [invoke it manually](https://learn.microsoft.com/en-us/azure/azure-functions/functions-manually-run-non-http) 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-scale#function-app-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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-error-pages#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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#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](https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/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](https://learn.microsoft.com/en-us/azure/azure-functions/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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-deployment-technologies#trigger-syncing)                 |
| 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](https://learn.microsoft.com/en-us/azure/azure-functions/configure-monitoring#configure-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](https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-types#log-search-alerts)).

The pattern built for absence is a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-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](https://docs.cronradar.com/integrations/dotnet). NuGet installs `CronRadar` 1.0.13 (checked October 24, 2026):

```bash
dotnet add package CronRadar
```

Create 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings#use-application-settings), where the SDK reads it. A [Key Vault reference](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) keeps the key out of the app's configuration, as long as the app's managed identity can read the secret:

```bash
az functionapp config appsettings set \
  --name contoso-maintenance --resource-group contoso-prod \
  --settings "CRONRADAR_API_KEY=@Microsoft.KeyVault(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:

```csharp
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](https://docs.cronradar.com/schedules). 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](https://cronradar.com/blog/dotnet-backgroundservice-scheduled-tasks).

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](https://cronradar.com/dotnet) 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.
