newsletter

TickerQ: The Modern .NET Job Scheduler That Beats Quartz and Hangfire

Download source code
6 min read
Copied

Deploy Your App in 5 minutes with Fly.io (Sponsored)

Tired of juggling cloud regions, YAML files, and complex ops just to get your app live? Fly.io makes it easy.

Deploy straight from your Dockerfile — your app runs close to users worldwide with Anycast IPs, WireGuard-secured networking, and smart autoscaling built in.

Unlike traditional serverless platforms, Fly.io supports stateful apps. Full-stack deployments, managed Postgres, and persistent volumes are all built-in. Host your frontend, API, and database together — globally.

With predictable pricing and micro instances for side projects and startups, Fly.io makes global app hosting accessible to everyone.

You basically need 2 commands to deploy and run your app:

shell
$ fly launch Creating app in \fly\guides\hellodotnet ? Choose an app name (leave blank to generate one): hellodotnet ? Select Organization: demo (demo) ? Select region: ord (Chicago, Illinois (US)) Admin URL: https://fly.io/apps/hellodotnet Hostname: hellodotnet.fly.dev ? Would you like to set up a Postgresql database now? No ? Would you like to set up an Upstash Redis database now? No Wrote config file fly.toml ? Would you like to deploy now? Yes Deploying hellodotnet ... $ flyctl apps open Opening https://hellodotnet.fly.dev/

If you're a .NET developer, check out the Fly.io .NET guide and see how easy it is to deploy and run global.

👉 Get started on Fly.io today

Copied

Intro

Scheduling background jobs is a fundamental part of many .NET applications — whether it's sending emails, processing reports, or syncing data.

For years, developers have relied on tools like Quartz.NET and Hangfire.

While they are great tools, they have several drawbacks.

Quartz.NET drawbacks:

  • Reflection-based job registration
  • Complex API
  • Heavy configuration
  • Clumsy parameter passing into the jobs
  • No built-in EF Core integration (only via 3rd party packages)
  • No built-in dashboard for job monitoring (only via 3rd party packages)
  • No built-in retries

Hangfire drawbacks:

  • Reflection-based job registration
  • Limited DI-support
  • Limited async support
  • Some built-in storage providers require a commercial license

TickerQ is a fresh take on job scheduling in .NET — offering a modern, clean API, built-in persistence, EF Core integration, and a beautiful dashboard for job monitoring.

It removes much of the boilerplate and ceremony we're used to, and instead provides a developer-friendly, code-first experience.

In this post, we will explore:

  • How to set up TickerQ in a .NET project
  • Integrating TickerQ with EF Core
  • Creating and scheduling jobs (recurring and one-time)
  • Dynamically registering jobs
  • Using the TickerQ Dashboard to monitor job execution

Let's dive in!

Copied

Getting Started with TickerQ

TickerQ is available as a NuGet package that targets .NET Standard 2.1 and is compatible with any .NET Core 3.1+ application. It works out of the box with ASP.NET Core and doesn't require any additional configuration files — everything is code-first and dependency-injection friendly.

Follow these steps to get started with TickerQ:

Step 1: Install the NuGet Package

bash
dotnet add package TickerQ

This adds the core TickerQ library. You can optionally install other packages like TickerQ.EntityFrameworkCore and TickerQ.Dashboard (we'll cover that in the next sections).

Step 2: Register TickerQ in the Program.cs

TickerQ plugs directly into your application startup pipeline. Add the following lines in your Program.cs:

csharp
builder.Services.AddTickerQ(); var app = builder.Build(); app.UseTickerQ(); app.Run();

AddTickerQ method registers all the required services for TickerQ. UseTickerQ method adds the middleware that handles the incoming requests.

That's it. No background services to manage. No additional hosted services. TickerQ takes care of that for you under the hood.

Step 3: Define a Job

TickerQ jobs are just regular class methods, and classes don't need to inherit any interfaces or base classes. Simply add the TickerFunction attribute to any method in any class.

Here is an example of a job that creates reports:

csharp
public class CreateReportJob { private readonly ReportDbContext _dbContext; public CreateReportJob(ReportDbContext dbContext) { _dbContext = dbContext; } [TickerFunction(functionName: "Send Notifications", cronExpression: "0 * * * *")] public async Task CreateReport(TickerFunctionContext tickerContext, CancellationToken cancellationToken) { var report = new Report { Title = $"Scheduled Report - {DateTime.UtcNow:yyyy-MM-dd HH:mm}", Content = $"This is an automatically generated report created at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}", CreatedAt = DateTime.UtcNow }; _dbContext.Reports.Add(report); await _dbContext.SaveChangesAsync(cancellationToken); } }

In this example, TickerQ will call the CreateReport method every hour.

The cronExpression parameter is optional and can be used to schedule recurring jobs based on a cron expression. If you omit it, you can either configure the schedule when registering TickerQ in the DI or specify it dynamically.

Dependency injection is supported out of the box, and you can specify as many job methods in a single class as you want.

Here, the TickerFunctionContext is used to access additional information about the job execution.

You can also use the context to cancel or delete the job:

csharp
tickerContext.CancelTicker(); await tickerContext.DeleteAsync();

You can also reference cron expressions from appsettings.json using %Section:Key% syntax:

csharp
[TickerFunction(FunctionName: "ExampleMethod", CronExpression: "%CronTicker:EveryMinute%")] public void ExampleMethod() { }
json
{ "CronTicker": { "EveryMinute": "* * * * *" } }

Step 4: Handling Exceptions (optional)

To handle exceptions, you can implement the ITickerExceptionHandler interface and register it in the DI:

csharp
public class TickerExceptionHandler : ITickerExceptionHandler { public async Task HandleExceptionAsync(Exception exception, Guid tickerId, TickerType tickerType) { // your logic... } public async Task HandleCanceledExceptionAsync(Exception exception, Guid tickerId, TickerType tickerType) { // your logic... } }
csharp
services.AddTicker(opt => { opt.SetExceptionHandler<TickerExceptionHandler>(); });

Now let's explore how to integrate TickerQ with EF Core.

Copied

Integrating TickerQ with EF Core

To make TickerQ production‑ready, especially in cases where your app may be restarted or scaled across instances, you'll want persistence.

EF Core provides TickerQ with a relational store for job state, history, locking, and recovery.

First, install the EF Core extension for TickerQ:

bash
dotnet add package TickerQ.EntityFrameworkCore

In your service registration, enable EF Core features when calling AddTickerQ(...). For example:

csharp
services.AddTickerQ(options => { // Set a fallback timeout to check for missed jobs and execute. options.UpdateMissedJobCheckDelay(TimeSpan.FromSeconds(10)); options.SetInstanceIdentifier("TickerQ"); // Configure the EF Core–backed operational store for TickerQ metadata, locks, and state. options.AddOperationalStore<ReportDbContext>(efCoreOptions => { // Apply custom model configuration only during EF Core migrations // (design-time). The runtime model remains unaffected. // efCoreOptions.UseModelCustomizerForMigrations(); // On app start, cancel tickers left in Expired or InProgress (terminated) states // to prevent duplicate re-execution after crashes or abrupt shutdowns. efCoreOptions.CancelMissedTickersOnAppStart(); // Defined cron-based functions are auto-seeded in the database by default. // Example: [TickerFunction(..., "*/5 * * * *")] // Use this to ignore them and keep seeds runtime-only. efCoreOptions.IgnoreSeedMemoryCronTickers(); }); });

TickerQ uses EF Core to define its own internal tables/entities.

You have two options:

1. Use built‑in model customization for migrations

If you call UseModelCustomizerForMigrations(), the TickerQ entity configurations are applied automatically during migrations. This keeps things clean.

2. Manual configuration in the DbContext

If you don't use the model customizer, you need to apply the entity configurations in OnModelCreating explicitly, e.g.:

csharp
public class ReportDbContext : DbContext { public ReportDbContext(DbContextOptions<ReportDbContext> options) : base(options) { } public DbSet<Report> Reports { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.HasDefaultSchema(DbConsts.SchemaName); // Adds TickerQ schema to EntityFrameworkCore modelBuilder.ApplyConfiguration(new TimeTickerConfigurations(schema: DbConsts.SchemaName)); modelBuilder.ApplyConfiguration(new CronTickerConfigurations(schema: DbConsts.SchemaName)); modelBuilder.ApplyConfiguration(new CronTickerOccurrenceConfigurations(schema: DbConsts.SchemaName)); // ... } }

After setup, create your EF migration as usual with the EF Core CLI command:

bash
dotnet ef migrations add InitialMigration -c ReportDbContext

Here is what the database schema looks like with TickerQ tables:

Screenshot_1

Here is how you can seed initial tickers (time-based and cron-based):

csharp
efCoreOptions.UseTickerSeeder( async timeTicker => { await timeTicker.AddAsync(new TimeTicker { Id = Guid.NewGuid(), Function = "Create Report", ExecutionTime = DateTime.UtcNow.AddSeconds(5) }); }, async cronTicker => { await cronTicker.AddAsync(new CronTicker { Id = Guid.NewGuid(), Expression = "0 0 * * *", // every day at 00:00 UTC Function = "Create Report" }); } );

You can use the TimeTicker to schedule a job to be executed at a specific time.

Copied

Dynamically Registering Jobs

TickerQ supports both one‑time / delayed jobs (called TimeTickers) and recurring or cron‑based jobs (called CronTickers).

You can schedule jobs dynamically via API endpoints or within the app startup, with retry policies, custom intervals, and more.

Suppose you have an API endpoint where a user requests sending a notification at a future date/time.

You can use the ITimeTickerManager<TimeTicker> to schedule such a job:

csharp
public record NotificationJobContext(string Title, string Content); app.MapPost("/api/schedule‑notification", async (NotificationRequest request, ITimeTickerManager<TimeTicker> timeTickerManager) => { if (request.ScheduledTime <= DateTime.Now) { return Results.BadRequest("Scheduled time must be in the future"); } // Create the typed job data. var jobData = new NotificationJobContext(request.Title, request.Content); // Schedule the job. var result = await timeTickerManager.AddAsync(new TimeTicker { Function = "Send Notifications", ExecutionTime = request.ScheduledTime.ToUniversalTime(), Request = TickerHelper.CreateTickerRequest(jobData), Retries = 3, RetryIntervals = new[] { 30, 60, 120 } // Retry after 30s, 60s, then 2 min }); return Results.Ok(new { JobId = result.Result.Id, Message = $"Notification '{request.Title}' scheduled for {request.ScheduledTime}" }); });

Here I pass the NotificationJobContext to the job and I can extract it in the job method:

csharp
public class NotificationJob { private readonly ILogger<NotificationJob> _logger; public const string TitleKey = "Title"; public const string ContentKey = "Content"; public NotificationJob(ILogger<NotificationJob> logger) { _logger = logger; } [TickerFunction(functionName: "Send Notifications", cronExpression: "0 0 * * *" )] public Task Execute(TickerFunctionContext<NotificationJobContext> tickerContext, CancellationToken cancellationToken) { var title = tickerContext.Request.Title; var content = tickerContext.Request.Content; // ... return Task.CompletedTask; } }

Alternatively, you can use the ICronTickerManager<CronTicker> to schedule a recurring job:

csharp
var result = await cronTickerManager.AddAsync(new CronTicker { Function = "Send Notifications", Request = TickerHelper.CreateTickerRequest(jobData), Expression = "* * * * *", Retries = 3, RetryIntervals = new[] { 30, 60, 120 } // Retry after 30s, 60s, then 2 min });

Here is how you can update and delete a CronTicker:

csharp
// Update a CronTicker by ID // You get the ID when scheduling the job (result.Result.Id) await cronTickerManager.UpdateAsync(tickerId, ticker => { ticker.Description = "Updated cron description"; ticker.Expression = "*/10 * * * *"; // every 10 minutes }); // Delete a CronTicker by ID await cronTickerManager.DeleteAsync(tickerId);
Copied

Using the TickerQ Dashboard

TickerQ has a built‑in, first‑party dashboard UI that gives you real‑time visibility and control over all your scheduled jobs (both time‑based and cron‑based).

It's useful for debugging, maintaining, and managing production workloads.

Add the TickerQ.Dashboard NuGet package to your project.

bash
dotnet add package TickerQ.Dashboard

Register dashboard support when configuring TickerQ in DI:

csharp
builder.Services.AddTickerQ(options => { // ... options.AddOperationalStore<ReportDbContext>(efCoreOptions => { // ... }); options.AddDashboard(x => { x.BasePath = "/tickerq-dashboard"; x.EnableBasicAuth = true; }); });

You can also configure authentication for the dashboard UI; see the documentation for more details.

TickerQ supports Basic Authentication out of the box for the dashboard UI. You can configure credentials in your appsettings.json:

csharp
"TickerQBasicAuth": { "Username": "admin", "Password": "admin" }

To access the dashboard, navigate to https://localhost:5001/tickerq-dashboard in your browser (whatever your port is).

Enter admin/admin to log in:

Screenshot_2

Screenshot_3

Copied

Summary

TickerQ is a modern, lightweight, and production-ready scheduling library for .NET applications — offering a refreshing alternative to Hangfire and Quartz.NET.

You can see how much more code Quartz requires when compared to TickerQ in this blog post.

Here is the full comparison table of TickerQ vs. Quartz vs. Hangfire:

Screenshot_5

You can find the image source - here.

Hope you find this newsletter useful. See you next time.

You can download source code for this newsletter for free
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Not sure where you stand? Take the free .NET Developer Level Test:

  • Find out your real level — Junior to Senior+
  • 15 minutes across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When completed you get a personalized report: your level, your strongest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Take the free test

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.