.NET BackgroundService Scheduled Tasks With PeriodicTimer and Cron
Contents
To run a scheduled task in .NET without Hangfire or Quartz.NET, subclass BackgroundService, loop until the stoppingToken is canceled, and wait between runs with PeriodicTimer for a fixed interval or with the Cronos library for a cron schedule. The loop itself is a dozen lines. The production work is in what most samples leave out: by default an unhandled exception in the loop stops the whole application, every instance of the app runs its own copy of the loop, shutdown waits 30 seconds by default, and nothing tells you when the loop stops running. This guide covers .NET 8, 9, and 10 and Cronos 0.13.0.
Whether to use a library instead is a separate decision, compared in Hangfire vs. Quartz.NET vs. BackgroundService. This post assumes you've chosen the plain hosted service. For a fluent scheduler that also runs inside the process, see Coravel scheduler monitoring.
How BackgroundService Runs Your Loop#
AddHostedService<T>() registers your class as a singleton, and the host calls ExecuteAsync once at startup. The Task it returns is the lifetime of your loop: once it returns or throws, the loop is over and the host never calls it again. The stoppingToken is canceled when the host starts a graceful shutdown (hosted services in ASP.NET Core).
// Program.cs: the same line works with WebApplication.CreateBuilder or Host.CreateApplicationBuilder.
builder.Services.AddHostedService<NightlyCleanupWorker>();.NET 10 changed how ExecuteAsync starts (breaking change notice):
| Version | Code before the first await in ExecuteAsync |
An exception thrown there |
|---|---|---|
| .NET 8 and 9 | Runs synchronously inside StartAsync and blocks other hosted services from starting |
Is returned from StartAsync, so the host fails to start |
| .NET 10 | Runs on the thread pool through Task.Run; startup doesn't wait for it |
Is handled like any other ExecuteAsync failure, covered below |
The difference is visible in the .NET 9 and .NET 10 source. If something must finish before the rest of the app starts, put it in an override of StartAsync before calling base.StartAsync, not at the top of ExecuteAsync.
Interval Schedules: Use PeriodicTimer#
PeriodicTimer, added in .NET 6, is the right primitive for "every N minutes" inside a loop. Three properties from its source matter:
- One waiter at a time. Only one
WaitForNextTickAsynccall may be pending; a second concurrent call throwsInvalidOperationException. Your work runs between waits, so a loop never overlaps itself. - Missed ticks coalesce. The timer behaves like an auto-reset event. If a run outlasts the period, the ticks that passed collapse into one and the next wait returns immediately: one catch-up run, not a burst.
- No drift. The underlying timer queue compensates for late firing, so the time your work takes doesn't push later ticks back. The first tick arrives one full period after the timer is created; call the work once before the loop if you need an immediate first run.
public sealed class PriceRefreshWorker(ILogger<PriceRefreshWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
// Throws OperationCanceledException at shutdown; returns false only if the timer is disposed.
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await RefreshPricesAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Price refresh failed; the next tick still runs.");
}
}
}
// Simulated work.
private static Task RefreshPricesAsync(CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}Letting the OperationCanceledException escape at shutdown is fine: the host checks for a canceled task during shutdown and doesn't log it as an error (Host.cs).
How the alternatives compare:
| Approach | Can runs overlap? | Drift |
|---|---|---|
PeriodicTimer loop |
No | None |
System.Threading.Timer callback |
Yes: callbacks run on thread-pool threads and can execute simultaneously when the work outlasts the period | None |
await work; await Task.Delay(period) loop |
No | Yes: each cycle is the period plus the run time |
Cron Schedules With Cronos and a Time Zone#
An interval can't express "02:00 every day" or "weekdays at 09:00". Those are wall-clock times, and the gap between them changes across daylight saving time. Compute the next occurrence with Cronos and wait until it. The current release is 0.13.0, published April 29, 2026 (NuGet, checked October 24, 2026).
dotnet add package Cronos --version 0.13.0The worker below runs a nightly cleanup at 02:00, gives each run a time limit and its own DI scope, and survives a failed run. It relies on the implicit usings of the Worker and Web SDKs. CleanupJob is the scoped class shown in the next section.
// NightlyCleanupWorker.cs
using Cronos;
public sealed class NightlyCleanupWorker(
IServiceScopeFactory scopeFactory,
ILogger<NightlyCleanupWorker> logger) : BackgroundService
{
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(30);
private static readonly TimeSpan MaxWaitSlice = TimeSpan.FromMinutes(5);
// Parsed when the worker is constructed, so a typo stops the app at startup, not at 02:00.
private readonly CronExpression _schedule = CronExpression.Parse("0 2 * * *");
// UTC here. For local wall-clock time, use an IANA ID such as
// TimeZoneInfo.FindSystemTimeZoneById("America/New_York").
private readonly TimeZoneInfo _zone = TimeZoneInfo.Utc;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
DateTimeOffset? next = _schedule.GetNextOccurrence(DateTimeOffset.UtcNow, _zone);
if (next is null)
{
logger.LogError("The cleanup schedule has no future occurrence; stopping the loop.");
return;
}
await WaitUntilAsync(next.Value, stoppingToken);
try
{
await RunOnceAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Nightly cleanup for {Occurrence} failed; the next run is still scheduled.", next.Value);
}
}
}
private async Task RunOnceAsync(CancellationToken stoppingToken)
{
// A per-run time limit turns a hang into an exception.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(RunTimeout);
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
CleanupJob job = scope.ServiceProvider.GetRequiredService<CleanupJob>();
await job.RunAsync(timeout.Token);
}
private static async Task WaitUntilAsync(DateTimeOffset due, CancellationToken stoppingToken)
{
// Task.Delay waits for an interval, not a clock time. Waiting in slices re-reads
// the clock, so a corrected system clock still lands on time.
TimeSpan remaining;
while ((remaining = due - DateTimeOffset.UtcNow) > TimeSpan.Zero)
{
await Task.Delay(remaining < MaxWaitSlice ? remaining : MaxWaitSlice, stoppingToken);
}
}
}// Program.cs (worker service)
var builder = Host.CreateApplicationBuilder(args);
// Register AppDbContext with AddDbContext as usual.
builder.Services.AddScoped<CleanupJob>();
builder.Services.AddHostedService<NightlyCleanupWorker>();
builder.Build().Run();The slices also keep each wait under the limit of Task.Delay, which throws for delays over 4,294,967,294 ms (about 49.7 days). Because the next occurrence is computed after each run, a run that overruns the next occurrence skips it instead of starting a second copy, the same coalescing PeriodicTimer gives you. One gap remains: an occurrence that falls while the process is down (a deploy at 02:00, a crash) is missed, because the loop has no persisted schedule to catch up from.
What Cronos does with the time zone, per its README:
- You pass a
TimeZoneInfoand get back aDateTimeOffsetwith the correct offset. Cronos throws if you hand it a non-UTCDateTime, which is why the worker usesDateTimeOffset.UtcNow. - Spring forward: a time that doesn't exist that day runs at the next valid time.
30 2 * * *runs at 03:00 on the transition day; the run isn't lost. - Fall back: an expression with no
*, range, or step in its minute or hour field, such as30 1 * * *, runs once, before the clocks change. An interval expression such as*/30 * * * *fires through both copies of the repeated hour. - Day fields: when both day of month and day of week are restricted, Cronos requires both to match, while Vixie cron runs on either.
IANA IDs such as America/New_York work on Windows too since .NET 6, unless NLS or globalization-invariant mode is enabled (FindSystemTimeZoneById). For field-by-field cron syntax, see cron expression syntax.
One Unhandled Exception Stops the Whole App#
Since .NET 6, when ExecuteAsync throws, the host logs the exception and, under the default BackgroundServiceExceptionBehavior.StopHost, stops the application (.NET 6 breaking change). In ASP.NET Core the web server shares that host, so one bad night for the cleanup job takes the API down with it.
The other setting, BackgroundServiceExceptionBehavior.Ignore, keeps the host running. It doesn't restart anything. The host only logs the fault, so the app keeps serving requests and passing health checks while the job never runs again: a failure that is both silent and permanent.
Neither setting suits a recurring job. Catch per run instead, as both workers above do:
- The
trywraps one run, so a failure costs one run, not the loop. LogErrorwith the exception keeps the failure visible. Catching without logging just moves the silence.- The
when (!stoppingToken.IsCancellationRequested)filter lets shutdown cancellation propagate, so stopping the app isn't logged as a job failure. A run that hits its own 30-minute timeout is still caught and logged, because only the linked token was canceled.
Leave StopHost as the default. With per-run catching, an exception that escapes ExecuteAsync means the loop itself is broken, and stopping is the honest response.
Resolve DbContext and Other Scoped Services per Run#
EF Core registers a DbContext as scoped by default. Inject one into a hosted service's constructor and, in Development, scope validation fails the app at startup. Elsewhere the context is created in the root container, where its lifetime is "effectively promoted to singleton": one context and one change tracker shared by every run for the life of the process.
Inject IServiceScopeFactory, create a scope per run, resolve the job from it, and dispose the scope when the run ends. RunOnceAsync above does this with CreateAsyncScope. The job itself is ordinary DI code:
// CleanupJob.cs
using Microsoft.EntityFrameworkCore;
public sealed class CleanupJob(AppDbContext db, ILogger<CleanupJob> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
DateTime cutoff = DateTime.UtcNow.AddDays(-30);
int deleted = await db.AuditEntries
.Where(entry => entry.CreatedAtUtc < cutoff)
.ExecuteDeleteAsync(cancellationToken);
logger.LogInformation("Deleted {Count} audit entries older than {Cutoff}.", deleted, cutoff);
}
}AppDbContext stands for your EF Core context with an AuditEntries set. ExecuteDeleteAsync sends one DELETE without loading rows into the change tracker.
Every Instance Runs the Loop#
A hosted service runs in every process that hosts it. Scale to three instances and the nightly cleanup runs three times. Even a single-replica Kubernetes Deployment briefly runs two copies during a rolling update: the default strategy's 25% maxSurge rounds up to one extra pod, and 25% maxUnavailable rounds down to zero, so the new pod starts before the old one stops (Deployment strategy).
| Option | How | Trade-off |
|---|---|---|
| A dedicated worker | Register the hosted service only in a separate worker deployment with one replica, using the Recreate strategy if it runs on Kubernetes |
One replica is a single point of failure, and Recreate has a gap with no worker during deploys |
| A database lock | Before each run, take an exclusive sp_getapplock with @LockOwner = 'Session' and @LockTimeout = 0 (SQL Server) or pg_try_advisory_lock (PostgreSQL). Skip the run if you don't get it; release the lock in finally on the same open connection |
Prevents overlap, not repeats: an instance whose timer fires a moment after the first run finished takes the free lock and runs again. To run once per occurrence, also record each scheduled time you completed under a unique key |
| Hangfire or Quartz.NET clustering | Shared storage decides which server runs each occurrence | A dependency and a storage schema; see the comparison |
Shut Down Without Cutting a Run in Half#
When the host stops, BackgroundService.StopAsync cancels the stoppingToken and waits for ExecuteAsync to finish, but only until the host's shutdown timeout expires. HostOptions.ShutdownTimeout is 30 seconds since .NET 7 (dotnet/runtime#63712; it was 5 seconds before). After that, the host stops waiting and the process exits with the run still in flight (generic host docs).
Pass the token all the way down: into EF Core calls, HttpClient requests, and delays, as CleanupJob does. A run that ignores the token keeps going until it is killed mid-write, so make each run safe to repeat (checklist).
Raise the timeout if a run legitimately needs longer to wind down. It covers the whole host, not each service:
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(60));Check the process manager's deadline, because it wins. docker stop sends SIGKILL after 10 seconds by default for Linux containers, and Kubernetes after terminationGracePeriodSeconds, which defaults to 30. Set the stop timeout or grace period above ShutdownTimeout, or the host's timeout never gets a chance to apply.
Monitor the Loop From Outside the Process#
An in-process scheduler has no witness. If the process is down, unloaded for being idle, or still running with a dead loop under Ignore, nothing runs and nothing is logged. Health checks don't catch it, because they report on the process.
External schedule monitoring fills the gap: each run reports to a service that knows the schedule, and a report that doesn't arrive on time raises the alert. With the CronRadar .NET SDK (CronRadar 1.0.13 on NuGet, checked October 24, 2026), wrap the body of RunOnceAsync:
dotnet add package CronRadar --version 1.0.13private async Task RunOnceAsync(CancellationToken stoppingToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(RunTimeout);
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("nightly-cleanup", async () =>
{
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
CleanupJob job = scope.ServiceProvider.GetRequiredService<CleanupJob>();
await job.RunAsync(timeout.Token);
}, schedule: "0 2 * * *");
}The SDK reads the key from the CRONRADAR_API_KEY environment variable; supply it from your secret store, with a separate CronRadar application and key per environment. What the wrapper does:
- Reports
start, thencompleteorfail. On failure it sends the exception message and rethrows the original exception, so the loop'scatchstill logs it. Afailalerts immediately. - Never breaks the job. Each SDK request times out after 5 seconds, and monitoring errors are caught instead of thrown, so a CronRadar outage can't fail a run.
- Registers the monitor. Passing
schedulecreates the monitor on the first run and recreates it if it is deleted. - Catches the silent cases. A job that stops reporting goes
criticalonce its expected time plus the monitor's grace period passes. Astartwith no finish is flagged as stuck once the next expected run time plus grace passes without a new report. That window is per job, not per run, which is whyRunOnceAsynckeeps its own timeout: a hang becomes a reported failure. A deploy that cancels a run in progress is also reported as a failure, which is accurate but will alert.
The schedule must be the UTC, five-field equivalent of the loop's schedule, because CronRadar evaluates schedules in UTC and rejects Cronos extras such as a seconds field, ?, L, W, or #. This loop runs at 02:00 UTC, so it is 0 2 * * *. If _zone were America/New_York, the run would land at 06:00 or 07:00 UTC depending on daylight saving time: send the earlier, 0 6 * * *, and set the monitor's grace period to at least an hour plus the run's usual duration in the CronRadar dashboard. The SDK has no per-call grace argument.
A PeriodicTimer interval needs an equivalent cron expression too: 15 minutes is */15 * * * *, which CronRadar expects at :00, :15, :30, and :45. A timer started at 10:07 fires at :07, :22, :37, and :52, so every run arrives seven minutes after it was expected. Either drive the loop with Cronos and */15 * * * * so runs land on the expected minutes, or set a grace period that covers the offset, which can be up to the full interval plus run time.
On several instances, put WrapAsync inside the branch that won the lock, so only the instance that actually ran reports. The .NET integration guide covers verification, and .NET monitoring shows how the base SDK fits next to the Hangfire and Quartz.NET integrations.
Frequently Asked Questions#
How do I run a scheduled task in ASP.NET Core without Hangfire?#
Register a BackgroundService with builder.Services.AddHostedService<T>() and loop inside ExecuteAsync, waiting with PeriodicTimer for intervals or until the next Cronos occurrence for cron schedules. The loop lives in the web process, so it only runs while that process does. On IIS the default application pool idle timeout of 20 minutes shuts down an app with no traffic, and Azure App Service unloads an idle app after 20 minutes unless Always On is enabled.
Is PeriodicTimer better than System.Threading.Timer for a BackgroundService?#
For recurring work in a loop, yes. PeriodicTimer lets you await the work, so runs can't overlap and exceptions flow through ordinary try/catch. A System.Threading.Timer callback fires on every period whether or not the previous one has finished. Since .NET 8, PeriodicTimer also accepts a TimeProvider and lets you change its Period while it runs.
Why did my BackgroundService stop running?#
Check first for an exception that escaped ExecuteAsync. Under the default StopHost behavior it stops the whole app; under Ignore the app stays up and only the loop is gone. Either way the host logs a "BackgroundService failed" error with the exception. Catch per run inside the loop, and check whether the process itself was stopped, recycled, or unloaded.
How do I use a cron expression in a .NET worker service?#
Add a cron library such as Cronos. Parse the expression once, call GetNextOccurrence(DateTimeOffset.UtcNow, zone) to get the next run, wait until it with capped Task.Delay slices, run the job, and repeat. Passing a TimeZoneInfo lets Cronos handle daylight saving time transitions for you.
Does a BackgroundService run on every instance of my app?#
Yes. Each process that registers the hosted service runs its own loop, including the old and new copies that overlap during a rolling deploy. Run the job in a dedicated single-instance worker, take a database lock and skip when another instance holds it, or use a scheduler with cluster coordination such as Hangfire or Quartz.NET.
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.