The incident
We were building a daily report generation job for a logistics client in Bangalore. The requirement was simple: generate the previous day's shipment summary every morning before 9 AM IST (Indian Standard Time) so the operations team had it waiting in their inbox when they started work. We wrote a Hangfire recurring job, set it to 0 9 * * * (9 AM, every day), and deployed to Azure App Service in the Southeast Asia region.
On the first morning in production, the report wasn't in anyone's inbox at 9 AM. It arrived at 2:30 PM. We checked the Hangfire dashboard — the job had executed successfully at the scheduled time. The cron expression said 9 AM. The job ran at 9 AM. The job ran at 9 AM UTC. In IST (UTC+5:30), that's 2:30 PM. The Azure VM had no idea we meant 9 AM Bangalore time.
Why Azure doesn't know your local time
Cloud providers run their virtual machines in UTC universally. It doesn't matter whether you deploy to East US, West Europe, or Southeast Asia — your .NET process reads DateTimeOffset.UtcNow and gets UTC. DateTime.Now returns UTC on Azure App Service and Azure Functions because the OS timezone is set to UTC. This is intentional: UTC avoids daylight saving time transitions causing jobs to run twice or not at all, and it makes logs from multi-region deployments comparable without timezone conversion.
Hangfire's recurring job scheduler stores next execution times in UTC and compares them against UTC. When you write RecurringJob.AddOrUpdate("daily-report", () => GenerateReport(), "0 9 * * *"), Hangfire interprets that cron expression in UTC. The job runs when the UTC clock reaches 09:00, regardless of where your team or your users are located.
The UTC offset calculation
Converting a local cron schedule to UTC requires knowing the UTC offset for the timezone, and being aware of DST transitions if applicable. IST (Indian Standard Time) is UTC+5:30 — a half-hour offset, which makes it slightly unusual compared to whole-hour offsets.
- 9:00 AM IST = 9:00 - 5:30 = 3:30 AM UTC
- A cron expression for 3:30 AM UTC is
30 3 * * *
Note that IST does not observe daylight saving time, so this offset is constant throughout the year. For timezones that do observe DST (US Eastern, UK, most of Europe), you need to check whether your desired local time falls during standard time or daylight time, since the UTC offset changes by one hour.
The fix in Hangfire
// Wrong: runs at 9 AM UTC, not 9 AM IST
RecurringJob.AddOrUpdate(
"daily-shipment-report",
() => _reportService.GenerateShipmentSummaryAsync(),
"0 9 * * *" // 9 AM UTC = 2:30 PM IST
);
// Correct approach 1: Use UTC-adjusted cron expression
RecurringJob.AddOrUpdate(
"daily-shipment-report",
() => _reportService.GenerateShipmentSummaryAsync(),
"30 3 * * *" // 3:30 AM UTC = 9:00 AM IST
);
// Correct approach 2: Specify timezone explicitly (Hangfire 1.7+)
// This is better because it handles DST transitions automatically
RecurringJob.AddOrUpdate(
"daily-shipment-report",
() => _reportService.GenerateShipmentSummaryAsync(),
"0 9 * * *", // 9 AM in the specified timezone
TimeZoneInfo.FindSystemTimeZoneById("India Standard Time") // Windows timezone ID
);
// IANA timezone IDs on Linux (Azure App Service Linux):
// TimeZoneInfo.FindSystemTimeZoneById("Asia/Kolkata")
// Cross-platform timezone handling using TimeZoneConverter NuGet package
// This maps IANA IDs to Windows IDs and vice versa:
// Install-Package TimeZoneConverter
var indianTime = TZConvert.GetTimeZoneInfo("Asia/Kolkata");
RecurringJob.AddOrUpdate(
"daily-shipment-report",
() => _reportService.GenerateShipmentSummaryAsync(),
"0 9 * * *",
indianTime
);
The timezone-aware approach (Approach 2) is significantly better than manually adjusting the UTC offset, because it handles cases where the local timezone observes daylight saving time. If your servers move from one timezone to another (e.g., a DR failover between regions), or if your business logic involves users in different timezones, explicit timezone declarations make intent unambiguous.
The same problem in Quartz.NET
// Quartz.NET also defaults to UTC for cron triggers
// To specify a timezone, use TimeZoneInfo in the CronTrigger builder:
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("daily-report-trigger")
.WithCronSchedule(
"0 0 9 * * ?", // 9 AM — will be interpreted in the specified timezone
x => x.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("India Standard Time"))
)
.Build();
Azure Functions timer triggers (cron) and UTC
// Azure Functions timer triggers also use UTC by default
// In your Function definition:
[FunctionName("DailyReport")]
public async Task Run(
[TimerTrigger("30 3 * * *")] TimerInfo timer, // 3:30 AM UTC = 9:00 AM IST
ILogger log)
{
log.LogInformation("Daily report triggered at {UtcNow} UTC", DateTime.UtcNow);
await _reportService.GenerateShipmentSummaryAsync();
}
// Azure Functions also support timezone specification via WEBSITE_TIME_ZONE
// app setting on Windows plans, or TZ environment variable on Linux plans.
// When set, timer triggers interpret cron expressions in that timezone.
// However, relying on an app setting for scheduling logic is fragile —
// prefer explicit UTC cron expressions or timezone parameters in code.
How to verify your cron expression before deploying
Before deploying any cron-scheduled job to a cloud environment, verify what time it will actually run by checking the next execution times in UTC. The Cron Expression Generator shows you the next 10 execution times for any cron expression — paste your expression, verify the UTC times are what you intend, then translate to local time to confirm it matches your requirement.
For the IST case: if the generator shows the next executions at 03:30 UTC, 03:30 UTC, 03:30 UTC... you know it will run at 9:00 AM IST every day. If it shows 09:00 UTC, you know it will run at 2:30 PM IST.
DST-aware scheduling for European and US timezones
For teams deploying jobs that target European or North American business hours, DST adds an extra complexity. A job scheduled to run at 9 AM CET (Central European Time, UTC+1) needs to run at 8 AM UTC in winter and at 7 AM UTC in summer (CEST is UTC+2). If you hardcode 0 8 * * *, the job runs at 9 AM in winter and 10 AM in summer — one hour off during summer time.
The correct solution is always the same: use timezone-aware scheduling APIs rather than hardcoded UTC offsets. Hangfire's TimeZoneInfo parameter, Quartz's .InTimeZone(), or ASP.NET Core's IHostedService combined with TimeZoneInfo.ConvertTime() all handle DST transitions automatically.
The generalizable lesson
Cloud servers live in UTC. Your users, your business hours, and your support staff may not. This is not an Azure-specific problem — AWS, GCP, and any Linux server you spin up will also run in UTC. Write all cron schedules in terms of UTC, or use timezone-aware scheduling APIs with explicit timezone declarations. Never assume that "9 AM" in a cron expression means 9 AM in any timezone other than UTC, and never rely on a server-side timezone setting for schedule correctness — that setting can change without notice and doesn't version-control cleanly alongside your code.