Every .NET application eventually needs background work. Send a welcome email after sign-up. Generate a nightly report. Retry a failed webhook. The moment you reach for a job scheduler, the shortlist is always the same: Hangfire or Quartz.NET. Both work. Both have been around for years. And both carry the same architectural baggage — they discover and invoke your jobs through runtime reflection.

That reflection tax is invisible at first. A 2ms penalty per job invocation barely registers when you are running a handful of jobs. Scale to 20,000 daily executions and you are burning 40 seconds of CPU time on plumbing alone. Worse, reflection-based discovery is hostile to NativeAOT and trimming, two compilation models that .NET has been pushing developers towards since .NET 8.

TickerQ takes a different approach. It replaces reflection with Roslyn source generators, wiring up job registration at compile time. No runtime scanning, no startup penalty, no AOT warnings. With 3,400 GitHub stars, 400,000 NuGet downloads, and a v10.3 release that landed in April 2026, it has crossed the threshold from interesting experiment to credible alternative.

What the source generator actually does

In Hangfire, you pass a method group or lambda to BackgroundJob.Enqueue, and the framework serialises the method reference plus arguments into storage. At execution time, it deserialises the reference, resolves the type via reflection, and invokes the method. Quartz.NET follows a similar pattern — your job classes implement IJob, and the scheduler discovers and instantiates them through reflection.

TickerQ flips this model. You decorate a method with [TickerFunction], and during compilation the source generator:

  1. Discovers every method carrying the attribute
  2. Validates the method signature (correct parameter types, return type)
  3. Emits registration code that maps function names to strongly-typed delegates
  4. Generates type-safe reference structs with global namespace prefixes

The result is a dictionary of function-name-to-delegate mappings baked into your assembly. At runtime, the scheduler looks up a function name and calls the delegate directly — no Activator.CreateInstance, no MethodInfo.Invoke, no reflection at all.

Jobs/InvoiceJobs.cs
public class InvoiceJobs(IInvoiceService invoiceService, ILogger<InvoiceJobs> logger)
{
    [TickerFunction("GenerateMonthlyInvoices", "0 2 1 * *")]
    public async Task GenerateMonthlyInvoices(
        TickerFunctionContext context,
        CancellationToken cancellationToken)
    {
        var count = await invoiceService.GenerateForCurrentMonthAsync(cancellationToken);
        logger.LogInformation("Generated {Count} invoices", count);
    }
}

That class uses primary constructors and standard dependency injection. There is no base class to inherit, no interface to implement. The [TickerFunction] attribute is all the source generator needs.

Getting started

Three packages cover most use cases:

dotnet add package TickerQ
dotnet add package TickerQ.EntityFrameworkCore
dotnet add package TickerQ.Dashboard

Registration follows the standard .NET pattern:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddTickerQ(options =>
{
    options.SetMaxConcurrency(8);
    options.SetExceptionHandler<JobExceptionHandler>();

    options.AddOperationalStore<AppDbContext>(ef =>
    {
        ef.UseModelCustomizerForMigrations();
        ef.CancelMissedTickersOnApplicationRestart();
    });

    options.AddDashboard(basePath: "/tickerq");
    options.AddDashboardBasicAuth();
});

var app = builder.Build();

app.UseTickerQ();
app.Run();

AddTickerQ registers the scheduler, its hosted service, and the source-generated function registry. UseTickerQ starts the scheduling loop. That is the entire bootstrap.

Cron jobs and one-off jobs

TickerQ distinguishes between two job types. CronTickers run on a recurring schedule defined by a cron expression. TimeTickers fire once at a specific time.

Cron-based recurring jobs

The cron expression can be hardcoded in the attribute or pulled from configuration using the %Section:Key% syntax:

Jobs/CleanupJobs.cs
public class CleanupJobs(AppDbContext db)
{
    [TickerFunction("PurgeExpiredSessions", "%Jobs:SessionPurgeCron%")]
    public async Task PurgeExpiredSessions(
        TickerFunctionContext context,
        CancellationToken cancellationToken)
    {
        var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
        await db.Sessions
            .Where(s => s.ExpiresAt < cutoff)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
appsettings.json
{
  "Jobs": {
    "SessionPurgeCron": "0 3 * * *"
  }
}

// TIP

Pulling cron expressions from configuration means you can adjust schedules per environment without recompiling. Use appsettings.Development.json to run jobs more frequently during testing.

Time-based one-off jobs

For delayed or scheduled one-off work, inject ITimeTickerManager and create a TimeTicker:

Services/OnboardingService.cs
public class OnboardingService(ITimeTickerManager timeTickerManager)
{
    public async Task ScheduleWelcomeEmail(int userId, DateTimeOffset sendAt)
    {
        await timeTickerManager.AddAsync(new TimeTicker
        {
            Function = "SendWelcomeEmail",
            ExecutionTime = sendAt,
            Request = TickerHelper.CreateTickerRequest(new { UserId = userId }),
            Retries = 3,
            RetryIntervals = [30, 60, 300]
        });
    }
}

The RetryIntervals array defines backoff in seconds — 30s, then 60s, then 5 minutes. If all retries are exhausted, the job moves to a failed state visible in the dashboard.

Cron jobs can also be created dynamically through ICronTickerManager when you need to let users define their own schedules at runtime.

Persistence with EF Core

TickerQ stores job state in your existing database through EF Core. It creates three tables: TimeTickerConfigurations, CronTickerConfigurations, and CronTickerOccurrenceConfigurations. There are two approaches to including these in your migrations.

If you pass UseModelCustomizerForMigrations() during configuration, TickerQ hooks into EF Core's model building pipeline and adds its entities automatically:

Example.cs
options.AddOperationalStore<AppDbContext>(ef =>
{
    ef.UseModelCustomizerForMigrations();
});

After adding this, run dotnet ef migrations add AddTickerQTables and the migration will include the scheduler tables alongside your application tables.

Manual configuration

If you prefer explicit control, apply the entity configurations directly in your DbContext:

Data/AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.ApplyConfiguration(new TimeTickerConfigurations());
        builder.ApplyConfiguration(new CronTickerConfigurations());
        builder.ApplyConfiguration(new CronTickerOccurrenceConfigurations());
    }
}

// NOTE

TickerQ supports PostgreSQL, SQL Server, SQLite, and MySQL through EF Core. For Redis-backed persistence, use the separate TickerQ.Redis package instead.

The MapTicker API

Version 10.3 introduced MapTicker, an alternative registration model inspired by minimal API route mapping. Instead of scattering [TickerFunction] attributes across your codebase, you define jobs in a central location:

Program.cs
app.MapTicker(ticker =>
{
    ticker.MapFunction<InvoiceJobs>("GenerateMonthlyInvoices",
        (job, ctx, ct) => job.GenerateMonthlyInvoices(ctx, ct));

    ticker.MapGroup("notifications", group =>
    {
        group.SetMaxConcurrency(2);
        group.MapFunction<NotificationJobs>("SendPush",
            (job, ctx, ct) => job.SendPushNotification(ctx, ct));
        group.MapFunction<NotificationJobs>("SendEmail",
            (job, ctx, ct) => job.SendEmailNotification(ctx, ct));
    });
});

Groups share configuration — concurrency limits, lifetime scope, retry policies. The notifications group above caps concurrent execution at 2, preventing your notification service from being overwhelmed.

Both the attribute approach and MapTicker are fully supported. Use attributes when jobs are self-contained and co-located with their logic. Use MapTicker when you want a birds-eye view of all registered jobs or need shared group configuration.

The real-time dashboard

TickerQ ships with a built-in dashboard powered by Vue.js and SignalR. Unlike Hangfire's dashboard, which polls for updates, TickerQ's pushes state changes in real time.

appsettings.json
{
  "TickerQBasicAuth": {
    "Username": "ops-team",
    "Password": "a-real-password-not-this-one"
  }
}

The dashboard shows running, queued, succeeded, and failed jobs with execution duration and retry history. You can trigger, pause, or cancel jobs directly from the UI. Since v10.3, the dashboard is fully NativeAOT-compatible — all endpoint handlers use JsonTypeInfo instead of reflection-based serialisation.

// WARNING

Basic auth over HTTP transmits credentials in plain text. Always deploy the dashboard behind HTTPS in production, or use a reverse proxy that terminates TLS.

Per-function concurrency control

A common pain point with job schedulers is a single slow job type consuming all available workers. TickerQ addresses this with per-function concurrency limits:

Example.cs
[TickerFunction("ProcessLargeImport", maxConcurrency: 2)]
public async Task ProcessLargeImport(
    TickerFunctionContext context,
    CancellationToken cancellationToken)
{
    var import = context.GetRequest<ImportRequest>();
    await importService.ProcessAsync(import.FileId, cancellationToken);
}

The global SetMaxConcurrency defines the total worker pool, while maxConcurrency on individual functions carves out limits within that pool. Setting the import job to 2 means at most two imports run simultaneously, regardless of how many are queued.

Running across multiple nodes

In production, you often run multiple instances of the same application behind a load balancer. TickerQ handles distributed coordination through EF Core row-level locking. When a node picks up a job, it marks itself as OwnerNode using SELECT ... FOR UPDATE semantics, ensuring no other node processes the same job.

With Redis persistence, TickerQ uses Lua scripts for atomic job acquisition and heartbeat-based dead-node detection. If a node goes silent, its claimed jobs are reassigned after a configurable timeout.

// IMPORTANT

Distributed locking depends on your database supporting row-level locks. SQLite does not — use PostgreSQL, SQL Server, or MySQL for multi-node deployments.

How it compares

TickerQ is not a drop-in replacement for every Hangfire or Quartz.NET deployment. Each scheduler occupies a different point in the trade-off space.

Hangfire excels at fire-and-forget simplicity. Its BackgroundJob.Enqueue(() => service.DoWork()) one-liner is hard to beat for ad-hoc background tasks. Hangfire Pro adds batches, continuations, and Redis support. If you need those specific workflow primitives, Hangfire is still the pragmatic choice.

Quartz.NET remains the most flexible option for complex scheduling. Calendar-based triggers, misfire policies, job listeners, and decades of production usage in enterprise environments give it an edge when scheduling requirements are intricate. Its clustering support is battle-tested at scale.

TickerQ wins on performance overhead and modern .NET alignment. Zero reflection means zero AOT warnings, lower startup cost, and faster per-invocation execution. The built-in EF Core persistence eliminates the need for Hangfire's separate storage schema. The real-time dashboard ships free (Hangfire's is free for basic use, but Pro features require a commercial licence).

The honest trade-off: TickerQ is younger. It has 56 open issues on GitHub. Its clustering is less battle-tested than Quartz.NET's. If you are running mission-critical batch processing in a financial system, Quartz.NET's maturity is worth the reflection cost. If you are building a modern .NET application that targets AOT or simply wants a lighter scheduler with less ceremony, TickerQ deserves serious consideration.

Common pitfalls

Forgetting to call UseTickerQ() — the scheduler registers its services with AddTickerQ(), but the background processing loop only starts when UseTickerQ() is called on the application builder. Missing this call means jobs are stored but never executed, which is particularly confusing because no error is thrown.

SQLite in multi-node deployments — SQLite locks the entire database file during writes. Running two application instances against the same SQLite database will cause lock contention and missed jobs. Use a client-server database for anything beyond local development.

Oversized job payloads — TickerHelper.CreateTickerRequest() serialises the request object to JSON and stores it in the database. Passing large objects (file contents, full entity graphs) bloats the job table and slows down the scheduler's polling queries. Pass identifiers instead and fetch the data inside the job.

Not handling CancellationToken — when the application shuts down or a job is cancelled from the dashboard, TickerQ signals through the cancellation token. Ignoring it means jobs continue running during shutdown, potentially corrupting state or delaying graceful termination.

Mixing attribute and MapTicker registration for the same function — registering the same function name through both mechanisms causes a startup exception. Pick one approach per function.

Summary