# .NET BackgroundService Scheduled Tasks With PeriodicTimer and Cron

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/dotnet-backgroundservice-scheduled-tasks

> Build a reliable .NET BackgroundService scheduled task: PeriodicTimer intervals, Cronos cron schedules with time zones, exceptions, scale-out, and shutdown.

**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](https://cronradar.com/blog/hangfire-vs-quartz-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](https://cronradar.com/blog/coravel-scheduler-monitoring).

## How BackgroundService Runs Your Loop

`AddHostedService<T>()` registers your class as a [singleton](https://learn.microsoft.com/en-us/dotnet/core/extensions/scoped-service), 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](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-10.0)).

```csharp
// 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](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/10.0/backgroundservice-executeasync-task)):

| 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](https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs) and [.NET 10](https://github.com/dotnet/runtime/blob/release/10.0/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs) 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](https://github.com/dotnet/runtime/blob/release/10.0/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs) matter:

- **One waiter at a time.** Only one `WaitForNextTickAsync` call may be pending; a second concurrent call throws `InvalidOperationException`. 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](https://github.com/dotnet/runtime/blob/release/10.0/src/libraries/System.Private.CoreLib/src/System/Threading/Timer.cs), 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.

```csharp
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](https://github.com/dotnet/runtime/blob/release/10.0/src/libraries/Microsoft.Extensions.Hosting/src/Internal/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](https://learn.microsoft.com/en-us/dotnet/api/system.threading.timer?view=net-10.0) 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](https://github.com/HangfireIO/Cronos) and wait until it. The current release is 0.13.0, published April 29, 2026 ([NuGet](https://www.nuget.org/packages/Cronos), checked October 24, 2026).

```bash
dotnet add package Cronos --version 0.13.0
```

The 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.

```csharp
// 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);
        }
    }
}
```

```csharp
// 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`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay?view=net-10.0), 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](https://github.com/HangfireIO/Cronos):

- **You pass a `TimeZoneInfo` and get back a `DateTimeOffset` with the correct offset.** Cronos throws if you hand it a non-UTC `DateTime`, which is why the worker uses `DateTimeOffset.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 as `30 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](https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo.findsystemtimezonebyid?view=net-10.0)). For field-by-field cron syntax, see [cron expression syntax](https://cronradar.com/blog/cron-expression).

## 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](https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/hosting-exception-handling)). 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](https://github.com/dotnet/runtime/blob/release/10.0/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs), 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 `try` wraps one run, so a failure costs one run, not the loop.
- `LogError` with 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](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-10.0). 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"](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/overview): 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:

```csharp
// 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`](https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete) 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](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)).

| 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`](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql) with `@LockOwner = 'Session'` and `@LockTimeout = 0` (SQL Server) or [`pg_try_advisory_lock`](https://www.postgresql.org/docs/current/functions-admin.html) (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](https://cronradar.com/blog/hangfire-vs-quartz-vs-backgroundservice)                                                                                                                              |

## 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](https://github.com/dotnet/runtime/pull/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](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-10.0)).

**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](https://cronradar.com/blog/prevent-errors-in-scheduled-automation)).

**Raise the timeout if a run legitimately needs longer to wind down.** It covers the whole host, not each service:

```csharp
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](https://docs.docker.com/reference/cli/docker/container/stop/) for Linux containers, and Kubernetes after `terminationGracePeriodSeconds`, which [defaults to 30](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/). 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`:

```bash
dotnet add package CronRadar --version 1.0.13
```

```csharp
private 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`, then `complete` or `fail`.** On failure it sends the exception message and rethrows the original exception, so the loop's `catch` still logs it. A `fail` alerts 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 `schedule` creates the monitor on the first run and recreates it if it is deleted.
- **Catches the silent cases.** A job that stops reporting goes `critical` once its expected time plus the monitor's grace period passes. A `start` with 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 why `RunOnceAsync` keeps 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](https://docs.cronradar.com/schedules) 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](https://docs.cronradar.com/integrations/dotnet) covers verification, and [.NET monitoring](https://cronradar.com/dotnet) 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](https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/applicationpools/add/processmodel) shuts down an app with no traffic, and Azure App Service unloads an idle app after 20 minutes unless [Always On](https://learn.microsoft.com/en-us/azure/app-service/configure-common) 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.
