Introduction
This article explains how to build a robust, production-ready Task Scheduler system using ASP.NET Core for the backend and Angular for the frontend. The scheduler supports user-created tasks, recurring schedules, background processing, retries, monitoring, and notifications. Examples use Hangfire and a lightweight scheduler approach so you can choose between managed and simple in-process solutions.
The guide is written in simple Indian English and aimed at senior developers who want a practical, copy-pasteable reference with real-world best practices.
What this system does
Users create tasks with schedule rules (one-time or recurring)
Backend stores scheduled jobs and executes tasks reliably
Supports retries, exponential backoff, and failure handling
Dashboard for monitoring, pausing, and cancelling tasks
Notifications via email or SignalR when tasks complete or fail
Support for long-running and short-running jobs
High-level architecture
[Angular SPA] <--- HTTPS/JWT ---> [ASP.NET Core API]
| |
| +--> [Hangfire / Scheduler Worker]
| |
| +--> [SQL Server / Redis] (job store)
|
+--> [SignalR Hub] (optional for realtime updates)
Notes
Hangfire uses a persistent storage (SQL Server, Redis) for job state and is easy to operate.
Alternatively use Quartz.NET for advanced scheduling policies.
For cloud-native, use Azure WebJobs or AWS EventBridge + Lambda depending on needs.
Database design (basic)
Main tables (if you keep custom metadata separate from scheduler store):
Users(Id, UserName, Email, PasswordHash, Role, CreatedAt)
ScheduledTasks(Id, OwnerId, Name, Type, PayloadJson, CronExpression, NextRunAt, LastRunAt, Status, RetryCount, MaxRetries, CreatedAt, UpdatedAt)
TaskHistory(Id, TaskId, RunAt, DurationMs, Status, ResultJson, ErrorMessage)
Notes
PayloadJson stores the data required to execute the job (e.g., export parameters).
Status can be Scheduled, Running, Succeeded, Failed, Cancelled.
Keep TaskHistory for audit and SLA reporting.
If using Hangfire or Quartz, they maintain their own tables; you can still keep lightweight ScheduledTasks to show user-friendly names and metadata.
Scheduling model and recurrence
Support these scheduling types:
One-time: runAt (specific DateTime)
Cron-based recurring schedules (CRON expressions)
Simple patterns (daily at time, weekly on weekdays, monthly on day X)
Design: Accept both cron expression and a friendly schedule object. Convert friendly schedules to cron internally.
Validation: Validate cron expressions on the server and provide immediate feedback in UI.
Backend options: Hangfire vs Quartz vs Custom
Hangfire (recommended for most web apps):
Easy to integrate with ASP.NET Core
Persistent storage (SQL Server/Redis)
Dashboard out of the box
Support for retries, continuations, and background workers
Quartz.NET
Custom in-process scheduler
Use IHostedService + BackgroundService to poll DB and execute due tasks
Simpler but less reliable for multi-instance setups unless you implement distributed locking (e.g., via SQL row lock or Redis lock)
Recommendation: use Hangfire for most needs; use Quartz when you need advanced triggers or clustering features not supported by Hangfire.
Example: Integrate Hangfire (Step-by-step)
Step 1: Add packages
dotnet add package Hangfire.Core
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
Step 2: Configure Hangfire in Program.cs
var builder = WebApplication.CreateBuilder(args);
// Hangfire
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddHangfireServer();
// other services
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseHangfireDashboard("/hangfire", new DashboardOptions { Authorization = new[] { new HangfireDashboardAuthorizationFilter() } });
app.MapControllers();
app.Run();
Security: Protect the Hangfire dashboard with role-based authorization and use HTTPS.
Step 3: Enqueue and schedule jobs from code
public class TaskSchedulerService : ITaskSchedulerService
{
public string ScheduleOneTimeJob(Guid taskId, DateTime runAt)
{
return BackgroundJob.Schedule(() => ExecuteTask(taskId), runAt - DateTime.UtcNow);
}
public string ScheduleRecurringJob(Guid taskId, string cronExpression)
{
RecurringJob.AddOrUpdate(taskId.ToString(), () => ExecuteTask(taskId), cronExpression);
return taskId.ToString();
}
[AutomaticRetry(Attempts = 3)]
public Task ExecuteTask(Guid taskId)
{
// load task, run, persist history
return Task.CompletedTask;
}
}
Notes
Use [AutomaticRetry] to customize retries and delays.
For recurring jobs, ensure idempotency; the same recurring job may run multiple times if previous run didn't finish properly.
Job execution and idempotency
Idempotency is critical for scheduled tasks. Always design tasks so multiple executions produce the same effect or are safe to run multiple times.
Strategies
Use unique run identifiers and check history table before processing.
Use transactional updates with status flags: set Running with a row-version and update to Succeeded or Failed atomically.
For long-running tasks, use heartbeats and timeouts to detect stuck jobs.
Retry and backoff policies
Use exponential backoff for retries. Hangfire supports retry attributes; for custom logic implement a wrapper:
public async Task ExecuteWithRetries(Func<Task> action, int maxRetries = 3)
{
var attempt = 0;
while (true)
{
try { await action(); return; }
catch (Exception ex) when (++attempt <= maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(delay);
}
}
}
Log all failures and persist the exception details into TaskHistory for analysis.
Distributed locking and multi-instance safety
If you run multiple API instances and use a custom scheduler, ensure only one instance picks up a job at a time.
Options
Use Hangfire or Quartz (they handle locking via persistent storage).
Use Redis-based locks (RedLock) for cross-instance safety.
Use SQL sp_getapplock or update a LockedBy column using WHERE LockedBy IS NULL and check affected rows.
Example SQL optimistic lock
UPDATE ScheduledTasks SET LockedBy = @instanceId, LockTakenAt = GETUTCDATE()
WHERE Id = @taskId AND (LockedBy IS NULL OR LockTakenAt < DATEADD(MINUTE, -5, GETUTCDATE()));
If rows affected = 1, you hold the lock.
Task types and payloads
Support generic task types that the worker can execute. Example task types:
ExportReport — payload contains filters and destination (email/URL)
SendEmail — payload contains subject, recipients, templateId
SyncExternalService — payload contains endpoint and credentials
CleanupJob — payload contains retention rules
Store Type (string) and PayloadJson. Use a dispatcher in worker to route to the correct handler.
public async Task ExecuteTask(Guid taskId)
{
var task = await _db.ScheduledTasks.FindAsync(taskId);
var handler = _handlers.GetHandler(task.Type);
await handler.Handle(task.PayloadJson);
}
Handlers should be registered via DI and implement an interface ITaskHandler.
API: Controllers and endpoints
Key endpoints:
POST /api/tasks — create scheduled task
GET /api/tasks — list tasks for user with filters and pagination
GET /api/tasks/{id} — get task details and history
POST /api/tasks/{id}/cancel — cancel a scheduled task
POST /api/tasks/{id}/run-now — trigger immediate run
GET /api/tasks/history — view execution history
Create Task example
[HttpPost]
public async Task<IActionResult> Create([FromBody] TaskCreateDto dto)
{
var userId = User.GetUserId();
var task = new ScheduledTask { /* map dto */ };
_db.ScheduledTasks.Add(task);
await _db.SaveChangesAsync();
// schedule in Hangfire
if (dto.Cron != null) _taskSchedulerService.ScheduleRecurringJob(task.Id, dto.Cron);
else _taskSchedulerService.ScheduleOneTimeJob(task.Id, dto.RunAt.Value);
return CreatedAtAction(nameof(Get), new { id = task.Id }, task);
}
Authorization: Only owners or admins should manage tasks. Validate payloads server-side and limit who can schedule sensitive job types.
Angular: UI and Services
Organise frontend modules:
tasks module: create, list, detail, history, run-now, cancel
shared module: date pickers, cron editor, confirmation modal
core module: auth, http interceptor, models
Cron editor and friendly UI
Use a friendly cron builder for users who do not know cron. Convert friendly rules to cron on client or server. Libraries: cron-editor (npm) or custom UI for daily/weekly/monthly options.
TaskService (Angular)
@Injectable({providedIn: 'root'})
export class TaskService {
private base = '/api/tasks';
constructor(private http: HttpClient) {}
create(dto: any) { return this.http.post(this.base, dto); }
list(query: any) { return this.http.get(this.base, { params: query }); }
get(id: string) { return this.http.get(`${this.base}/${id}`); }
cancel(id: string) { return this.http.post(`${this.base}/${id}/cancel`, {}); }
runNow(id: string) { return this.http.post(`${this.base}/${id}/run-now`, {}); }
history(id: string) { return this.http.get(`${this.base}/${id}/history`); }
}
Task list and detail
Show next run, last run, status and actions (run now, cancel, edit)
For recurring jobs show cron expression and friendly text
Show execution history with duration and result snippets
Notifications and real-time updates
Use SignalR to push task status updates to users. When a job finishes, worker broadcasts TaskUpdated event which clients subscribed to dashboard receive and refresh UI.
Worker snippet
await _hubContext.Clients.User(ownerId.ToString()).SendAsync("TaskUpdated", new { taskId = task.Id, status = task.Status });
Protect SignalR hub with authentication and ensure hub scaling (use Azure SignalR or backplane if multiple worker instances).
Monitoring and observability
Track these metrics:
Job success rate and failure rate
Average job execution time
Number of retries and longest running jobs
Queue length and worker health
Use Application Insights or Prometheus + Grafana. Also configure alerts for increased failure rate or backlog.
Testing strategy
Unit test handlers and service logic using in-memory DB or mocks
Integration tests for scheduling pipeline using Hangfire test server or local storage
E2E tests for UI flows (create task, run now, cancel)
Load test scheduling (many jobs scheduled at same time) to find bottlenecks
Security considerations
Validate and sanitize PayloadJson for dangerous content
Restrict who can schedule certain job types (e.g., SendEmail to external domains)
Use role-based authorization and fine-grained policies
Limit payload size and rate limit job creation
Use HTTPS and secure credentials for external systems used by job handlers
Scaling and deployment
Run workers separately from API servers for stable throughput
Use dedicated Hangfire workers in containerized environments
Use a persistent job store (SQL Server, Redis) shared across instances
Consider autoscaling workers based on queue length or CPU
Example: Implementing a CSV Export Job Handler
public class ExportReportHandler : ITaskHandler
{
private readonly ApplicationDbContext _db;
private readonly IBlobService _blob;
private readonly IEmailService _email;
public async Task Handle(string payloadJson)
{
var payload = JsonSerializer.Deserialize<ExportPayload>(payloadJson);
var data = await BuildReportData(payload);
using var stream = GenerateCsvStream(data);
var url = await _blob.UploadAsync(stream, "reports/report.csv");
await _email.SendAsync(payload.EmailTo, "Your report is ready", $"Download: {url}");
}
}
Design handler to be idempotent: if same payload processed twice, it should not duplicate external side-effects (e.g., do not send duplicate emails). Use run identifiers persisted in TaskHistory.
Deployment checklist
Use environment secrets (key vault) for connection strings and external credentials
Run Hangfire Dashboard under secure path and role-based access
Configure health checks for API and workers
Configure logging and rotate logs
Backup DB with job history retention policy
Final best practices
Keep tasks small and focused; prefer many small jobs over one giant job
Ensure idempotency and transactional consistency
Use persistent job store and avoid in-memory schedulers in multi-instance deployments
Implement monitoring, alerting and operational runbooks
Protect scheduler UI and APIs with strong authorization
Next steps
If you want, I can:
scaffold a ready-to-run GitHub repository with API, Hangfire configuration, and Angular UI,
provide full EF Core migrations and seed data for sample tasks,
generate Angular components (create, list, detail) with unit tests,
add PNG/SVG diagrams for architecture and sequence flows.
Tell me which one you want next.