Why TickerQ?
.NET has had job schedulers for a long time β Hangfire and Quartz.NET are the household names. TickerQ is a newer entrant that's worth a look for this kind of use case because it:
- Uses compile-time source generators instead of reflection to register job functions, so there's no runtime scanning overhead and it's fully AOT/trim-friendly.
- Persists jobs directly into your own EF Core
DbContext(SQL Server, PostgreSQL, MySQL, or SQLite) β no separate job store to stand up. - Ships with a built-in real-time dashboard (via SignalR) so you can watch jobs move through
Idle β Queued β InProgress β Done/Failedwithout building your own monitoring UI. - Has first-class retry support β
RetriesandRetryIntervalson the scheduled job itself β plus distributed locking so multiple app instances don't double-send the same email. - Supports both one-off, time-based jobs (
TimeTicker) and recurring cron jobs (CronTicker) β a scheduled email is naturally aTimeTicker.
The Data Model
The starting point is a single EF Core entity that represents a scheduled email and everything needed to track its delivery lifecycle:
public class EmailScheduler
{
public long Id { get; set; }
public string? FromName { get; set; }
public string FromEmail { get; set; } = null!;
public string? ToName { get; set; }
public string ToEmail { get; set; } = null!;
public string Subject { get; set; } = null!;
public string Body { get; set; } = null!;
public bool IsHtml { get; set; }
public string? BccEmail { get; set; }
public string? Attachments { get; set; }
public DateTimeOffset ScheduledAt { get; set; }
public bool SendImmediately { get; set; }
public string Status { get; set; } = "Pending"; // Pending, Sent, Failed
public int AttemptCount { get; set; }
public int MaxRetryCount { get; set; } = 3;
public int RetryIntervalMin { get; set; } = 15;
public DateTimeOffset? LastAttemptAt { get; set; }
public DateTimeOffset NextAttemptAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
A useful thing to notice: this entity already carries its own retry bookkeeping β AttemptCount, MaxRetryCount, RetryIntervalMin, NextAttemptAt. That's deliberate. Rather than leaning purely on TickerQ's built-in Retries/RetryIntervals (which live on the ticker, not on your business row), we keep retry state on the domain entity itself. That gives us:
- A row the MVC UI can query and display directly (status, attempt count, next attempt time) without reaching into TickerQ's internal tables.
- Retry behavior that's configurable per email, from user input, rather than fixed at job-registration time.
- A clean separation: TickerQ is the engine that wakes a job up at the right time; our entity is the record of truth for what happened.
TickerQ still does real work for us β it persists the βwhen should this runβ instruction, survives app restarts, gives us the dashboard, and guarantees a job only fires once even across multiple app instances.
Architecture at a Glance
Color key: blue = MVC/app layer, purple = TickerQ engine, amber = job execution & retry, green = success path, red = permanent failure. Dashed arrows show the re-queue / status-update loop.
- The controller creates an
EmailSchedulerrow and asksEmailSchedulingServiceto enqueue it. EmailSchedulingServicecallsITimeTickerManager<TimeTickerEntity>.AddAsync(...), passing only the row's Id as the job payload β never the whole email body, to keep the ticker payload small and the database as the single source of truth.- When the scheduled time arrives, TickerQ invokes
EmailDispatchJob.SendScheduledEmail, which reloads the entity, attempts delivery, and updatesStatus,AttemptCount,LastAttemptAt. - If delivery fails and
AttemptCount < MaxRetryCount, the job computesNextAttemptAt = now + RetryIntervalMinand enqueues anotherTimeTickerfor that time β a self-rescheduling retry loop driven entirely by the entity's own configuration. - If
AttemptCount >= MaxRetryCount, the row is markedFailedand no further ticker is scheduled.
Registering TickerQ
builder.Services.AddDbContext(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddTickerQ(opt =>
{
opt.AddOperationalStore(efOpt =>
{
efOpt.UseModelCustomizerForMigrations();
efOpt.CancelMissedTickersOnAppStart(); // don't fire a storm of overdue emails on restart
});
opt.AddDashboard(dash => dash.BasePath = "/ticker-dashboard");
});
var app = builder.Build();
app.UseTickerQ();
app.MapControllerRoute(name: "default", pattern: "{controller=EmailScheduler}/{action=Index}/{id?}");
app.Run();
AddOperationalStore<AppDbContext> tells TickerQ to store its own TimeTicker/CronTicker tables inside the sameDbContext as our domain entities β one database, one connection string, one set of migrations.
The Job Function
public class EmailDispatchJob
{
private readonly AppDbContext _db;
private readonly ISmtpEmailSender _sender;
private readonly ITimeTickerManager _tickerManager;
private readonly ILogger _logger;
public EmailDispatchJob(AppDbContext db, ISmtpEmailSender sender,
ITimeTickerManager tickerManager, ILogger logger)
{
_db = db; _sender = sender; _tickerManager = tickerManager; _logger = logger;
}
[TickerFunction(functionName: "SendScheduledEmail")]
public async Task SendScheduledEmail(TickerFunctionContext context, CancellationToken cancellationToken)
{
var emailId = context.Request;
var email = await _db.EmailSchedulers.FindAsync(new object[] { emailId }, cancellationToken);
if (email is null || email.Status == "Sent") return; // deleted or already sent
email.AttemptCount++;
email.LastAttemptAt = DateTimeOffset.UtcNow;
try
{
await _sender.SendAsync(email, cancellationToken);
email.Status = "Sent";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Email {Id} attempt {Attempt} failed", email.Id, email.AttemptCount);
if (email.AttemptCount < email.MaxRetryCount)
{
email.Status = "Pending";
email.NextAttemptAt = DateTimeOffset.UtcNow.AddMinutes(email.RetryIntervalMin);
await _tickerManager.AddAsync(new TimeTickerEntity
{
Function = "SendScheduledEmail",
ExecutionTime = email.NextAttemptAt.UtcDateTime,
Request = TickerHelper.CreateTickerRequest(email.Id)
}, cancellationToken);
}
else
{
email.Status = "Failed";
}
}
await _db.SaveChangesAsync(cancellationToken);
}
}
This is the heart of the fail/retry story: every failure re-reads the entity's own MaxRetryCount and RetryIntervalMin, so retry behavior is data-driven, not hardcoded, and each retry attempt is its own visible TimeTicker row you can inspect in the TickerQ dashboard.
Scheduling from the Controller
[HttpPost]
public async Task Create(EmailScheduler model)
{
if (!ModelState.IsValid) return View(model);
model.CreatedAt = DateTimeOffset.UtcNow;
model.Status = "Pending";
model.NextAttemptAt = model.SendImmediately ? DateTimeOffset.UtcNow : model.ScheduledAt;
_db.EmailSchedulers.Add(model);
await _db.SaveChangesAsync();
await _timeTickerManager.AddAsync(new TimeTickerEntity
{
Function = "SendScheduledEmail",
ExecutionTime = model.NextAttemptAt.UtcDateTime,
Request = TickerHelper.CreateTickerRequest(model.Id)
});
TempData["Message"] = $"Email #{model.Id} scheduled for {model.NextAttemptAt:g}.";
return RedirectToAction(nameof(Index));
}
SendImmediately and ScheduledAt collapse into one thing TickerQ understands: an ExecutionTime. βSend nowβ is just βsend at DateTimeOffset.UtcNowβ β there's no separate code path.
Retry & Fail, End to End
- Automatic retry: handled entirely inside
EmailDispatchJob, driven byAttemptCount/MaxRetryCount/RetryIntervalMinon the row. - Manual retry: a controller action resets
AttemptCount = 0,Status = "Pending",NextAttemptAt = now, and enqueues a freshTimeTickerβ useful once you've fixed a bad recipient address or an SMTP credential. - Permanent failure: once
AttemptCount >= MaxRetryCount, the row is markedFailedand stays that way until a human retries it. No silent infinite loop, no runaway job storm. - Visibility: the MVC
Indexview lists every row with a colored status badge (Pending= amber,Sent= green,Failed= red) plus attempt count and next attempt time, and TickerQ's own dashboard gives you a second, lower-level view of the underlying job executions if you need to debug timing or locking issues.
Wrapping Up
The pattern here generalizes well beyond email: any "do this thing later, and retry it a few times if it doesnβt work" feature β SMS notifications, webhook delivery, report generation β fits the same shape. TickerQ handles the when reliably (persisted, distributed-safe, restart-proof), while your own entity stays the readable, queryable source of truth for what happened. That split keeps your MVC screens simple and your retry logic fully under your control.
Note on API surface. TickerQ is under active development and small API details (e.g., exact TickerFunctionContext<T> member names, attribute constructor overloads) can shift slightly between versions. Always check the version you install against docs or IntelliSense before deploying to production.