A nightly export on Azure Functions is scheduled for 02:30. One morning the file is missing, another morning there are two, and after the clocks change it arrives an hour off. Nothing called the function, so nothing received an error.
A timer trigger is a best-effort schedule with one instance-wide lock, so the schedule expression is not evidence: the only proof that a run happened is the telemetry record of that invocation, and the only proof that it did not is a query that looks for the record and finds none. The documentation was read on 12 November 2026. The code targets the C# isolated worker model and the Node.js v4 programming model.
The schedule is six fields and starts with seconds
The timer trigger reference states that an NCRONTAB expression "is similar to a CRON expression except that it includes an additional sixth field at the beginning to use for time precision in seconds":
{second} {minute} {hour} {day} {month} {day-of-week}0 */5 * * * * is every five minutes and 0 30 9 * * 1-5 is 9:30 on weekdays.
The same page notes that the parser "supports both five field and six field format". So a five-field Unix cron line is accepted as it stands, and adding or dropping a field by mistake still parses, with every value read one position along. Count the fields. The values are clock times, not intervals: "a 5 in the hour field refers to 5:00 AM, not every 5 hours".
Time zones and daylight saving
The default is UTC. To use another zone, the reference says to create an app setting named WEBSITE_TIME_ZONE, and then: "the time is adjusted for time changes in the specific timezone, including daylight saving time and changes in standard time". Without the setting, a schedule keeps its UTC time and therefore moves by an hour in local terms.
Support depends on operating system and plan. The table adds the idle behaviour described in Event-driven scaling and Dedicated hosting plans.
| Plan | Idle behaviour as documented | WEBSITE_TIME_ZONE |
|---|---|---|
| Flex Consumption (Linux only) | Can scale to zero; always ready instances are optional | Not supported |
| Consumption | Can scale to zero; "Cold starts are expected in this plan" | Windows: Windows zone name. Linux: not supported |
| Premium | "Minimum of one instance always running" | Windows zone name, or tz database name on Linux |
| Dedicated (App Service) | "the Functions runtime goes idle after a few minutes of inactivity" unless Always on is enabled | Windows zone name, or tz database name on Linux |
On Linux Consumption and Flex Consumption the setting is worse than ignored: it "can create SSL-related issues and cause metrics to stop working for your app". On those plans, write the schedule in UTC and accept the seasonal shift. The documentation does not say what happens to an occurrence that falls inside the skipped or repeated hour.
Why a timer runs late, twice or not at all
Late. The timer object carries isPastDue, which "is true when the current function invocation is later than scheduled. For example, a function app restart might cause an invocation to be missed." Catching up depends on useMonitor: schedule monitoring "persists schedule occurrences to aid in ensuring the schedule is maintained correctly even when function app instances restart". It defaults to true for intervals of one minute or more and false for schedules that fire more than once a minute. The documentation does not state how many missed occurrences are replayed. Timers depend on blob storage through the AzureWebJobsStorage connection.
Twice. runOnStartup invokes the function "when the function app wakes up after going idle due to inactivity, when the function app restarts due to function changes, and when the function app scales out". The caution is direct: "Don't set runOnStartup to true in production. Using this setting makes code execute at highly unpredictable times." The scheduled tasks quickstart template sets it to true for JavaScript, TypeScript and Python, with a tip to turn it off for production. Scale-out alone does not duplicate runs: "only a single instance of a timer-triggered function is run across all instances".
Not at all. Four documented causes:
- A Dedicated plan without Always on.
- Triggers that were never synchronised. After deployment by external package URL, Local Git or FTPS you "must always manually sync triggers", by restarting the app or calling
syncfunctiontriggers. - Two function apps with the same host ID on one storage account. "If two function apps share the same identifying configuration and each uses a timer trigger, only one timer runs."
- A failed run. See the next section.
az rest --method post --url "https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Web/sites/<APP_NAME>/syncfunctiontriggers?api-version=2016-08-01"Retries, where the documentation disagrees with itself
The timer reference says: "the timer trigger doesn't retry after a function fails. When a function fails, it isn't called again until the next time on the schedule." The error handling and retries article lists Timer among the triggers that support function-level retry policies, and the C# sample on the timer page itself carries [FixedDelayRetry(5, "00:00:10")]. One consistent reading, ours and not Microsoft's, is that the first sentence describes the default.
A declared policy is still weak. The retry count "is stored in the memory of the instance", "The timer trigger doesn't resume on a new instance", and "For timer triggers, the retries can be less than the requested maximum number." In the isolated worker model it needs Microsoft.Azure.Functions.Worker.Extensions.Timer 4.2.0 or later.
Two functions that log what a run needs to prove
Each logs a start line, a completion line, the schedule status and the past-due flag.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace Contoso.Jobs;
public class NightlyExport
{
private readonly ILogger _logger;
public NightlyExport(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<NightlyExport>();
}
[Function(nameof(NightlyExport))]
[FixedDelayRetry(3, "00:00:30")]
public void Run(
[TimerTrigger("0 30 2 * * *", RunOnStartup = false, UseMonitor = true)] TimerInfo timer)
{
if (timer.IsPastDue)
{
_logger.LogWarning("NightlyExport is running late.");
}
_logger.LogInformation(
"NightlyExport started. Last = {last}, Next = {next}, PastDue = {pastDue}",
timer.ScheduleStatus?.Last, timer.ScheduleStatus?.Next, timer.IsPastDue);
// Do the work here. Let exceptions escape so the invocation is recorded as failed.
_logger.LogInformation("NightlyExport completed.");
}
}import { app, InvocationContext, Timer } from '@azure/functions';
export async function nightlyExport(myTimer: Timer, context: InvocationContext): Promise<void> {
if (myTimer.isPastDue) {
context.warn('nightlyExport is running late.');
}
context.log(
`nightlyExport started. Invocation ID: "${context.invocationId}", ` +
`last = ${myTimer.scheduleStatus?.last}, next = ${myTimer.scheduleStatus?.next}, ` +
`retry = ${context.retryContext?.retryCount ?? 0}`);
// Do the work here. Let errors escape so the invocation is recorded as failed.
context.log('nightlyExport completed.');
}
app.timer('nightlyExport', {
schedule: '0 30 2 * * *',
runOnStartup: false,
useMonitor: true,
retry: {
strategy: 'fixedDelay',
delayInterval: { seconds: 30 },
maxRetryCount: 3,
},
handler: nightlyExport,
});The schedule can also be an app setting name wrapped in percent signs, such as %EXPORT_SCHEDULE%.
Both samples were built before publication: the C# class compiles in an isolated worker project on .NET 10 with the Timer extension 4.x, and the TypeScript file passes the compiler against version 4 of @azure/functions.
Confirming executions in Application Insights
Analyze Azure Functions telemetry defines the two tables that matter: requests holds "One request for each function invocation", and traces holds runtime logs and "traces from your function code". The operation name is the function name. This lists every recorded run of the last week:
requests
| where timestamp > ago(7d)
| where cloud_RoleName == "<APP_NAME>"
| where operation_Name == "NightlyExport"
| project timestamp, name, success, resultCode, duration, cloud_RoleInstance
| order by timestamp descOne row per scheduled occurrence is the proof. Two rows close together is a duplicate, and cloud_RoleInstance shows which instance ran each. For what a run logged, the runtime adds InvocationId to customDimensions:
traces
| where timestamp > ago(7d)
| where operation_Name == "NightlyExport"
| project timestamp, message, severityLevel, customDimensions["InvocationId"]
| order by timestampBefore counting rows, use the sampling query in the same article to confirm that requests are not being sampled.
Alerting when a run is missing
An absence produces no record, so the alert query must manufacture a row. summarize without a by clause returns a single record even for empty input, and count() defaults to 0 (summarize operator):
requests
| where timestamp > ago(25h)
| where operation_Name == "NightlyExport" and success == true
| summarize runs = count()
| where runs == 0We ran the three queries against a live Application Insights resource. The first returned the recorded run, the second its log lines with the invocation ID, and this one returned no row for a function that had run in the window and one row for a function name that had not.
In a log search alert rule scoped to the Application Insights resource, measure Table rows with a static threshold of greater than 0. The query range is capped at two days.
Microsoft adds a caveat in Choose the right type of alert rule: "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 in the logs." Ingestion latency can make a completed run look absent, so keep the window wider than the schedule interval.
What a timer is not suitable for
- Guaranteed execution. Nothing in the documentation promises that every occurrence runs. Design the job so the next run can do the missed work.
- Exactly-once effects. The error handling article recommends designing for idempotency, and a retry or a past-due catch-up makes that a requirement.
- Jobs that can outlast their interval. The timer reference says a timer "will not trigger again if there is an outstanding invocation still running", while the eternal orchestrations article says a CRON timer "runs at fixed times regardless of whether the previous run finished". The two pages do not agree, so do not depend on either. An eternal orchestration in Durable Functions "waits for the work to complete before scheduling the next iteration, so runs never overlap".
- Processing every window after an outage. In Azure Logic Apps, the Sliding Window trigger "goes back and processes the missed recurrences", which the Recurrence trigger does not.
Related reading
- A readiness probe that checks the database: proving that a service is able to work, not only that it is running.
- A retry that sends a POST twice: why a job that can run twice has to be idempotent.
Support independent engineering knowledge
Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.
Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com