🚀 Introduction: Why You Need Multi-Timer Background Jobs
Have you ever needed to run different background tasks at different times—say, daily at 2 AM and another at 4 PM—in a .NET Core Worker Service? If you’re building microservices, automation systems, or scheduling engines, a one-size-fits-all timer won’t cut it. That’s where multi-timer scheduling in a .NET Core Worker Service becomes a powerful approach.
In this post, you’ll learn how to:
- Create a .NET Core Worker Service project in Visual Studio.
- Implement multiple timed background jobs using
System.Threading.Timer
. - Structure the service for clarity and maintainability.
- Understand when to use native timers and when to consider alternatives like Quartz.NET.
Let’s get our hands dirty.
🛠️ Creating a .NET Core Worker Service in Visual Studio
Before diving into timers, let’s set up the base project.
🧰 Step-by-Step Setup
Open Visual Studio and select Create new project, then choose worker service template:

Name the project MultiTimerWorkerService
. Choose .NET 8.0+ as the framework and click Create
⏱️ What Is a Timer and Why Multiple?
The System.Threading.Timer
class allows you to execute code at specific intervals. In most examples, it’s used to repeat one job. But real-world systems often require multiple jobs at different times—which is exactly what we’ll implement.
💡 Use Case: Auto-Reconciliation and Report Cleanup
Let’s say we have two background tasks:
- Task 1: Reconcile transactions every day at 2 AM.
- Task 2: Clean up report files every day at 4 PM.
We’ll schedule them using separate timers within the same Worker
class.
🧱 Full Source Code: Multi-Timer Worker Service
Here’s the complete working implementation.
public class Worker : BackgroundService
{
private Timer _reconcileTimer;
private Timer _cleanupTimer;
private readonly ILogger<Worker> _logger;
private readonly IConfiguration _config;
public Worker(ILogger<Worker> logger, IConfiguration config)
{
_logger = logger;
_config = config;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
ScheduleTaskAt(2, 0, PerformReconciliation, out _reconcileTimer);
ScheduleTaskAt(16, 0, PerformCleanup, out _cleanupTimer);
return Task.CompletedTask;
}
private void ScheduleTaskAt(int hour, int minute, Func<Task> action, out Timer timer)
{
var now = DateTime.Now;
var scheduledTime = DateTime.Today.AddHours(hour).AddMinutes(minute);
if (now > scheduledTime)
scheduledTime = scheduledTime.AddDays(1);
var delay = scheduledTime - now;
timer = new Timer(async _ =>
{
await action();
}, null, delay, TimeSpan.FromDays(1));
}
private async Task PerformReconciliation()
{
var logPath = _config.GetValue<string>("logFilePath");
var message = $"[2AM Task] Reconciliation started at {DateTime.Now}\n";
await File.AppendAllTextAsync($"{logPath}/reconciliation-log.txt", message);
// Simulate work
await Task.Delay(1000);
}
private async Task PerformCleanup()
{
var logPath = _config.GetValue<string>("logFilePath");
var message = $"[4PM Task] Cleanup started at {DateTime.Now}\n";
await File.AppendAllTextAsync($"{logPath}/cleanup-log.txt", message);
// Simulate cleanup
await Task.Delay(1000);
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_reconcileTimer?.Change(Timeout.Infinite, 0);
_cleanupTimer?.Change(Timeout.Infinite, 0);
return base.StopAsync(cancellationToken);
}
public override void Dispose()
{
_reconcileTimer?.Dispose();
_cleanupTimer?.Dispose();
base.Dispose();
}
}
🔍 Key Insights and Design Decisions
🔄 Why Separate Timers?
Using separate timers ensures each task runs independently. This prevents:
- Blocking issues
- Timer drift
- Complexity in handling task-specific intervals
📁 External Configuration
Storing log file paths or execution times in appsettings.json
keeps your service flexible:
{
"logFilePath": "C:\\Logs"
}
🧪 Testing Tips
To simulate the jobs quickly for testing:
- Replace the scheduled times with times just 1–2 minutes from now.
- Log outputs to file or console to verify execution.
📊 Comparison Table: Timers vs Quartz.NET
Feature | System.Threading.Timer | Quartz.NET |
---|---|---|
Setup Complexity | Simple | Moderate |
Multiple Jobs Support | Manual (via multiple timers) | Built-in |
Cron Expression Support | ❌ | ✅ |
Dependency Injection | Manual | Fully integrated |
Job Persistence | ❌ | ✅ (via DB stores) |
Best For | Lightweight apps | Complex scheduling needs |
🔗 Learn more about Quartz.NET here
🧠 Personal Insight: When to Use What
From my experience building banking automation systems, Timers are perfect for simple daily jobs (e.g., reconciliation, alerts), especially when you want total control over scheduling logic without external dependencies.
However, for SaaS platforms or systems with dozens of jobs, Quartz.NET becomes essential.
📎 Useful Resources
🎯 Conclusion
Creating a multi-timer background job scheduler in a .NET Core Worker Service is easier than it seems. With careful planning and structure, you can schedule multiple independent tasks using lightweight Timer
instances. Whether you’re automating reconciliation, reports, cleanups, or alerts, this pattern scales well for many real-world applications.
💬 What’s Next?
If you found this guide helpful:
- 💡 Share your thoughts or challenges in the comments.
- 📧 Subscribe for more hands-on .NET tutorials.
- 🔄 Bookmark this for future reference!
And if you’d like me to walk you through a Quartz.NET setup next, just drop a message!