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

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

Database design (basic)

Main tables (if you keep custom metadata separate from scheduler store):

Notes

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:

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):

Quartz.NET

Custom in-process scheduler

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

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

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

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:

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:

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:

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

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:

Use Application Insights or Prometheus + Grafana. Also configure alerts for increased failure rate or backlog.

Testing strategy

Security considerations

Scaling and deployment

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

Final best practices

Next steps

If you want, I can:

Tell me which one you want next.