ASP.NET Core  

Task Scheduler — ASP.NET Core + Angular

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

  • More control and advanced scheduling features

  • Good for distributed systems and clustering

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.