# Coravel Scheduler: Scheduling and Monitoring Tasks in ASP.NET Core

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/coravel-scheduler-monitoring

> How Coravel's scheduler runs tasks in ASP.NET Core: cron syntax, time zones, overlap, errors, what it never does, and how to monitor each task from outside.

**Coravel's scheduler runs scheduled tasks inside your ASP.NET Core process: you register it with `AddScheduler()`, define each task in code with `UseScheduler()`, and set when it runs with methods such as `.EveryMinute()`, `.DailyAt(2, 0)`, or `.Cron("0 2 * * *")`, evaluated in UTC unless you call `.Zoned(...)`.** Everything lives in memory. Coravel doesn't persist tasks, doesn't replay runs missed while the app was down, runs every task on every instance, and logs nothing about a failed task unless you configure it to. This guide covers Coravel 6.0.2, the latest release on [NuGet](https://www.nuget.org/packages/Coravel) (checked October 25, 2026), and reads [its source at that release](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Coravel.csproj) where the docs are silent.

## Set Up Coravel's Scheduler in ASP.NET Core

```bash
dotnet add package Coravel --version 6.0.2
```

Coravel 6.0.2 targets .NET 6; the examples below use C# 12 primary constructors, so they need .NET 8 or later. The task is an invocable, a class implementing `IInvocable`, which is the form the [scheduler docs](https://docs.coravel.net/Scheduler/#invocables) recommend:

```csharp
// Program.cs
using Coravel;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScheduler();
builder.Services.AddTransient<NightlyCleanup>(); // Invocables must be registered.

var app = builder.Build();

app.Services.UseScheduler(scheduler =>
{
    scheduler.Schedule<NightlyCleanup>()
        .Cron(NightlyCleanup.Schedule)
        .PreventOverlapping(nameof(NightlyCleanup));
})
.OnError(exception =>
    app.Logger.LogError(exception, "A Coravel scheduled task failed."));

app.MapGet("/", () => "OK");

app.Run();
```

```csharp
// NightlyCleanup.cs
using Coravel.Invocable;

public sealed class NightlyCleanup(ILogger<NightlyCleanup> logger) : IInvocable, ICancellableInvocable
{
    public const string Schedule = "0 2 * * *"; // 02:00 UTC

    // Coravel sets this before each run and cancels it when the app stops.
    public CancellationToken CancellationToken { get; set; }

    public async Task Invoke()
    {
        int deleted = await DeleteExpiredSessionsAsync(CancellationToken);
        logger.LogInformation("Deleted {Count} expired sessions.", deleted);
    }

    // Simulated work that observes the token.
    private static async Task<int> DeleteExpiredSessionsAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
        return 42;
    }
}
```

Three details from the source:

- **Each run gets its own DI scope.** Coravel creates a scope and resolves the invocable from it on every run ([ScheduledEvent.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Event/ScheduledEvent.cs#L98-L125)), so injecting a scoped EF Core `DbContext` is safe.
- **A missing registration fails at run time, not at startup.** The resolve happens inside the per-run `try`, so forgetting `AddTransient<NightlyCleanup>()` turns every occurrence into an exception that only the error handling described below reports.
- **A malformed expression fails at startup.** `UseScheduler` runs your callback immediately ([SchedulerServiceRegistration.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/SchedulerServiceRegistration.cs#L35-L40)), and `.Cron(...)` validates its string there.

For an async lambda instead of an invocable, use `ScheduleAsync(...)`. Passed to `Schedule()`, it compiles as an `async void` action Coravel can't await, which the docs [warn against](https://docs.coravel.net/Scheduler/#async-tasks).

## How Coravel Decides When a Task Runs

`AddScheduler()` registers a hosted service that starts a one-second timer once the application has started ([SchedulerHost.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/HostedService/SchedulerHost.cs#L35-L82)). On each tick:

- **Minute-level schedules are checked once a minute.** Every interval method from `EveryMinute()` up, and every `.Cron(...)` task, is evaluated only when the UTC second is 0. Seconds-based tasks (`EverySecond()` through `EverySeconds(n)`) are evaluated on every tick ([Scheduler.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Scheduler.cs#L222-L246)).
- **Late ticks are replayed.** If the timer falls behind under load, Coravel runs each missed second in order. Set `Coravel:Schedule:LogTickCatchUp` to `true` to [log the catch-up](https://docs.coravel.net/Scheduler/#logging-tick-catch-up).
- **Tasks due in the same tick run one after another.** A slow task delays the others on its worker. `scheduler.OnWorker("reports")` gives the tasks registered after it [their own thread](https://docs.coravel.net/Scheduler/#schedule-workers).
- **Each tick runs independently.** A task still running when it's due again starts a second copy, unless it has `.PreventOverlapping(...)`.

Two modifiers change whether a due task runs. `.When(async () => ...)` checks a predicate and skips the occurrence when it returns false. `.RunOnceAtStart()` also runs the task on the first tick after startup, [whatever its schedule](https://docs.coravel.net/Scheduler/#force-run-a-task-at-app-startup).

The interval methods are shorthand for cron expressions ([ScheduledEvent.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Event/ScheduledEvent.cs#L133-L269)):

| Method                                          | Expression, in UTC unless zoned                                                 |
| ----------------------------------------------- | ------------------------------------------------------------------------------- |
| `EveryMinute()`                                 | `* * * * *`                                                                     |
| `EveryFiveMinutes()` to `EveryThirtyMinutes()`  | `*/5 * * * *`, `*/10 * * * *`, `*/15 * * * *`, `*/30 * * * *`                   |
| `Hourly()`, `HourlyAt(15)`                      | `0 * * * *`, `15 * * * *`                                                       |
| `Daily()`, `DailyAtHour(13)`, `DailyAt(13, 30)` | `0 0 * * *`, `0 13 * * *`, `30 13 * * *`                                        |
| `Weekly()`                                      | `0 0 * * 1`: Monday at midnight                                                 |
| `Monthly()`                                     | `0 0 1 * *`                                                                     |
| `.Weekday()`, `.Monday()`, and the other days   | Add to the day-of-week field: `.DailyAt(9, 0).Weekday()` is `0 9 * * 1,2,3,4,5` |
| `EverySecond()` to `EverySeconds(n)`            | No cron equivalent                                                              |

### Coravel Reads a Subset of Cron

Coravel parses cron itself. It accepts five space-separated fields containing `*`, numbers, lists (`1,2,3`), ranges (`1-5`), ranges with a step (`0-30/5`), and `*/n`. It throws `MalformedCronExpressionException` for names such as `MON-FRI`, for `?`, `L`, `W`, or `#`, for a sixth field, and for a field that mixes a list with a range, such as `1-5,10` ([CronExpression.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Cron/CronExpression.cs), [CronExpressionComplexPart.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Cron/CronExpressionComplexPart.cs#L19-L47)). Some expressions it accepts mean something different elsewhere:

| Expression    | Coravel 6.0.2 runs               | cronie runs                          | CronRadar expects             |
| ------------- | -------------------------------- | ------------------------------------ | ----------------------------- |
| `0 0 */2 * *` | Even days: 2nd, 4th, ... 30th    | Odd days: 1st, 3rd, ... 31st         | Odd days                      |
| `0 0 1 */3 *` | March, June, September, December | January, April, July, October        | January, April, July, October |
| `0 */5 * * *` | 05:00, 10:00, 15:00, 20:00       | Also 00:00                           | Also 00:00                    |
| `0 9 * * 7`   | Never                            | Sundays                              | Rejects the schedule          |
| `0 9 1-7 * 1` | The first Monday of the month    | The 1st through 7th and every Monday | The first Monday of the month |

The step rows come from how Coravel evaluates `*/n`: a value matches when it divides evenly by n, with 0 counted as the field's size, 60 for minutes or 24 for hours ([CronExpressionPart.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Cron/CronExpressionPart.cs#L39-L66)). In cronie's [crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html), `*` stands for "first-last", so a step counts from the field's first value. Minute and hour steps that divide 60 or 24 evenly, such as `*/15` or `*/6`, mean the same everywhere. For any other step, write its start: `0 0 1-31/2 * *`, `0 0 1 1-12/3 *`, and `0 0-23/5 * * *` mean the same to Coravel, cronie, and CronRadar.

Coravel compares the day-of-week field with .NET's `DayOfWeek`, which runs 0 to 6, so use `0` for Sunday. In the last row Coravel and CronRadar agree against cronie: Coravel requires all five fields to match ([CronExpression.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Cron/CronExpression.cs#L46-L53)), as CronRadar does when both day fields are restricted. An expression that avoids these differences, on a task that isn't zoned, works unchanged for both, which is why `NightlyCleanup` keeps it in one constant.

## Time Zones: UTC Unless You Call Zoned()

Coravel evaluates every schedule in UTC [by default](https://docs.coravel.net/Scheduler/#intervals). `.Zoned(TimeZoneInfo)` makes a single task follow a zone's wall clock:

```csharp
scheduler.Schedule<SendDailyDigest>()
    .DailyAt(8, 0)
    .Zoned(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"));
```

On .NET 6 and later, `FindSystemTimeZoneById` accepts IANA IDs such as `America/New_York` on Windows too, unless NLS or globalization-invariant mode is enabled ([TimeZoneInfo docs](https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo.findsystemtimezonebyid?view=net-10.0)). The `TimeZoneInfo.Local` in [Coravel's example](https://docs.coravel.net/Scheduler/#zoned-schedules) ties the schedule to whatever zone the server or container happens to be set to.

Coravel's docs warn only of "unexpected behavior" around daylight saving time. The source shows what it is: each minute, Coravel converts the current UTC time into the task's zone and matches the expression against the local wall-clock time ([ScheduledEvent.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Event/ScheduledEvent.cs#L82-L96), [ZonedTime.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Zoned/ZonedTime.cs)). A local time that doesn't exist on the night clocks spring forward never matches, so a 02:30 New York task doesn't run that day. A local time that occurs twice when clocks fall back matches twice, so a 01:30 task runs twice. Schedule zoned tasks outside the hour your zone skips or repeats, or keep them in UTC. [Cron daylight saving time](https://cronradar.com/blog/cron-daylight-saving-time) compares how other schedulers handle the change.

## What Coravel Does Not Do

- **Persist anything.** Tasks live in an in-memory dictionary that your `UseScheduler` callback fills at every startup ([Scheduler.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Scheduler.cs#L17-L40)). Delete the line and the task is gone, with no record that it existed.
- **Catch up after downtime.** Coravel replays ticks it missed while running, but its tick tracker starts from the current time when the app starts ([SchedulerHost.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/HostedService/SchedulerHost.cs#L26-L33)). A 02:00 run that falls during a deploy or a crash never happens. [Missed runs after downtime](https://cronradar.com/blog/missed-runs-after-downtime) covers what other schedulers do and how to add a watermark.
- **Coordinate instances.** Every instance runs every task. Asked about Kubernetes replicas, the maintainer answered, "[Each instance will execute the schedules](https://github.com/jamesmh/coravel/issues/101#issuecomment-531522942)." A rolling deploy that starts the new instance before stopping the old one briefly runs two copies, too.
- **Lock across processes or retry.** The README's FAQ answers no to [distributed locking](https://github.com/jamesmh/coravel#does-coravel-support-distributed-locking), suggesting the DistributedLock library inside `Invoke()`, and no to [retries](https://github.com/jamesmh/coravel#does-coravel-support-retry-mechanisms), suggesting Polly.
- **Run while the process doesn't.** The scheduler is a hosted service in your web app. IIS, for example, shuts down a worker process after 20 idle minutes by default ([processModel](https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/applicationpools/add/processmodel)).

### PreventOverlapping Guards One Process, for Up to 24 Hours

`.PreventOverlapping("key")` takes a lock named by the key before a run and releases it in a `finally` afterward. If the lock is held, Coravel skips that occurrence and logs nothing ([Scheduler.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Scheduler.cs#L170-L212)). The key must be unique among your tasks. Two limits:

- **The lock is in memory.** `AddScheduler()` registers an `InMemoryMutex` ([SchedulerServiceRegistration.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/SchedulerServiceRegistration.cs#L21-L33)), which a second instance never sees.
- **The lock expires after 24 hours.** Coravel takes it with a 1,440-minute timeout, so a run that lasts longer no longer blocks the next one ([issue #351](https://github.com/jamesmh/coravel/issues/351)).

A lock in shared storage stops two instances running a task at the same time, but not one after the other: an instance whose tick lands just after the first run finished takes the free lock and runs again. To run once per occurrence, also record each occurrence you complete under a unique key. [Prevent overlapping cron jobs](https://cronradar.com/blog/prevent-overlapping-cron-jobs) compares lock types, and [distributed cron](https://cronradar.com/blog/distributed-cron) covers the ways to run a job once across servers.

## Exceptions Are Caught, and by Default Nobody Hears About Them

When a task throws, Coravel catches the exception and [moves on to the next task](https://docs.coravel.net/Scheduler/#global-error-handling). What else happens depends on configuration ([Scheduler.cs](https://github.com/jamesmh/coravel/blob/6cd2f7935314d2eea7ddfe45f8b68d99d26c4e77/Src/Coravel/Scheduling/Schedule/Scheduler.cs#L152-L212)):

- **With `.OnError(...)`**, Coravel calls your handler with the exception. The handler isn't told which task failed; the stack trace is your clue.
- **With `.LogScheduledTaskProgress()`**, Coravel logs the exception at Error level, inside a log scope naming the invocable type for an invocable, and logs each start and finish at Debug. The method [changed in 6.0.0](https://docs.coravel.net/Scheduler/#logging-executed-task-progress) and no longer takes a logger argument.
- **With neither**, the exception is discarded and nothing is logged.

Use one of the two; with both, a logging `OnError` handler records every failure twice. Neither helps with a task that never runs: a process that's down or unloaded for being idle, a `When` predicate that always returns false, or a task deleted in a refactor raises no exception and writes no log line.

## When Coravel Is the Right Choice

Coravel fits an app on one instance, or work that's harmless to run on every instance, where an occurrence lost to a deploy costs little and you want schedules next to the code with no storage to operate. Once a task must run once across instances, catch up after downtime, or retry from persisted state, you need a scheduler with shared storage. [Hangfire vs. Quartz.NET vs. BackgroundService](https://cronradar.com/blog/hangfire-vs-quartz-vs-backgroundservice) weighs those options, and for a single task a [BackgroundService loop](https://cronradar.com/blog/dotnet-backgroundservice-scheduled-tasks) does the same job with no dependency.

## Monitor Coravel Tasks With CronRadar

CronRadar has no Coravel integration and does not discover Coravel tasks. Each task reports for itself, through the base .NET SDK, `CronRadar` 1.0.13 on [NuGet](https://www.nuget.org/packages/CronRadar) (checked October 25, 2026), or through HTTP check-ins. Nothing compares Coravel's task list with CronRadar's, so a task you delete from `UseScheduler` leaves a monitor that keeps expecting runs; pause or delete it too.

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

`Program.cs` stays as it is. The invocable wraps its work:

```csharp
// NightlyCleanup.cs
using Coravel.Invocable;

public sealed class NightlyCleanup(ILogger<NightlyCleanup> logger) : IInvocable, ICancellableInvocable
{
    public const string Schedule = "0 2 * * *"; // 02:00 UTC, read the same by Coravel and CronRadar

    public CancellationToken CancellationToken { get; set; }

    public async Task Invoke()
    {
        // A per-run limit turns a hang into a reported failure.
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(30));

        // The class shares its namespace's name, so qualify it fully.
        await CronRadar.CronRadar.WrapAsync("nightly-cleanup", async () =>
        {
            int deleted = await DeleteExpiredSessionsAsync(timeout.Token);
            logger.LogInformation("Deleted {Count} expired sessions.", deleted);
        }, schedule: Schedule);
    }

    // Simulated work that observes the token.
    private static async Task<int> DeleteExpiredSessionsAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
        return 42;
    }
}
```

The SDK reads its key from the `CRONRADAR_API_KEY` environment variable. Supply it from your secret store, with a separate CronRadar application and key for each environment. What the wrapper does:

- **Reports `start`, then `complete` or `fail`.** On failure it sends the exception message and rethrows the original exception, so Coravel's `OnError` still logs it. A `fail` alerts immediately.
- **Never breaks the task.** Each SDK request times out after 5 seconds, and monitoring errors are caught instead of thrown.
- **Creates the monitor.** The `schedule` creates it on the first report and recreates it if it's deleted. Schedule and grace period apply only when a report creates the monitor, so change them later in the dashboard, not in the constant; the SDK has no per-call grace period argument.
- **Catches the runs that never happen.** If no report arrives, the monitor goes `warning` once 02:00 UTC passes, and `critical`, with an alert, once it is past the monitor's grace period and at least two minutes late. That covers an app that was down, unloaded, or mid-deploy at 02:00.
- **Flags a stuck run, eventually.** Every report, including `start`, moves the expected time to the task's next occurrence. If that time plus the grace period passes while the last report is a `start`, the monitor goes `critical` as stuck. For a nightly task that's the next night, and the next run's `start` resets the window. There's no per-job maximum runtime, which is why `Invoke()` sets its own 30-minute limit.

At shutdown, Coravel cancels `CancellationToken` and [waits for running tasks](https://docs.coravel.net/Scheduler/#on-app-closing). A run that observes the token ends with an `OperationCanceledException`, which the wrapper reports as a failure: accurate, but a deploy during a run will alert.

### Report From Inside Invoke()

A report inside `Invoke()` fires only for runs that happen, which decides how each Coravel feature looks to CronRadar:

- **`PreventOverlapping`:** a skipped occurrence sends nothing. The run still in progress covers it if it finishes before the next occurrence plus the grace period; if it runs longer, the monitor goes `critical` as stuck, which is the right alert for a run that overran.
- **`When(...)`:** a skipped occurrence also sends nothing, and CronRadar treats it as missed. If skips are normal, move the check inside the wrapped delegate and return early, so every scheduled occurrence reports a completion.
- **`RunOnceAtStart()`:** the extra run reports like any other and raises no alert.
- **Several instances:** every instance that runs the task reports. Put `WrapAsync` inside the branch that won the lock, so only the instance that did the work reports.

### Send CronRadar the UTC Equivalent

CronRadar reads standard five-field cron [in UTC](https://docs.cronradar.com/schedules). For a task that isn't zoned, use the expression from the method table, adjusted for the differences above. For a zoned task, convert: `.DailyAt(8, 0).Zoned(...)` for New York runs at 13:00 UTC in winter and 12:00 UTC in summer. Send the earlier, `0 12 * * *`, and set the monitor's grace period in the dashboard to at least an hour plus the run's usual duration. After the first run, compare the monitor's next expected run with when Coravel will next run the task.

Seconds-based tasks have no five-field form, and CronRadar rejects a seconds field, so report such a task at most once a minute against `* * * * *`. Budget requests too: each wrapped run sends two, and check-ins are limited to 60 a minute and 1,000 an hour per API key. The hourly limit is about 16 a minute, or eight wrapped every-minute tasks. A check-in the limit rejects isn't recorded and looks like a missed run.

### Without the SDK: HTTP Check-Ins

You can send the SDK's three requests yourself: a POST to `https://cron.life/api/v1/ping/nightly-cleanup/start` before the work, then to `/complete` or `/fail`. Use the API key as the Basic-auth username with an empty password, pass the URL-encoded `schedule` as a query parameter, time each request out after 5 seconds, and catch every monitoring error so the task's own exception still reaches Coravel. HTTP also lets you set `gracePeriod` per monitor on the check-in that creates it, and send bounded `outcomes` on `/complete` for expectations you configure, which the 1.0.13 SDK can't. The [API reference](https://docs.cronradar.com/api-reference) lists the parameters.

## Frequently Asked Questions

### Does Coravel persist scheduled tasks?

No. Coravel 6.0.2 rebuilds tasks in memory from your `UseScheduler` code at every startup, so an occurrence that falls while the app is down never runs. Coravel Pro, a separate commercial product, lists [database persisted scheduling](https://docs.coravel.net/Pro/) among its features. Among open-source .NET schedulers, Hangfire and Quartz.NET with a database job store persist schedules.

### Does Coravel run tasks on every server?

Yes. Every instance that calls `UseScheduler()` runs every task, and `PreventOverlapping` locks only within one process. Run scheduled tasks on one dedicated instance, or take a distributed lock inside `Invoke()` and skip the run when another instance holds it.

### How do I run a Coravel task in a specific time zone?

Chain `.Zoned(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"))` after the interval method; without it, Coravel uses UTC. A zoned task in the hour the clocks skip doesn't run that day, and one in the hour they repeat runs twice.

### How do I catch exceptions in Coravel scheduled tasks?

Chain `.OnError(exception => ...)` to `UseScheduler(...)` and log there, or call `.LogScheduledTaskProgress()`; without either, Coravel discards the exception. Catch inside `Invoke()` when you need the task's context or a retry, then rethrow so Coravel and your monitoring still see the failure.

### Does CronRadar auto-discover Coravel tasks?

No. CronRadar has no Coravel integration, and its .NET auto-discovery covers Hangfire and Quartz.NET only. Wrap each Coravel task with the base SDK's `WrapAsync` or send HTTP check-ins; the first report that carries a `schedule` creates the monitor.
