Skip to content

Hosted Services & Background Work

Key Points

  • IHostedService is the base interface for work that runs alongside your app — start when host starts, stop when host stops. Two methods: StartAsync(ct), StopAsync(ct).
  • BackgroundService extends IHostedService for long-running work. You override ExecuteAsync(stopping) and run a loop.
  • Scheduled jobs in-process: Quartz.NET (cron-style triggers, robust scheduling, persistence) or Hangfire (job persistence with dashboard, retries, queues).
  • Scheduled jobs out-of-process: Kubernetes CronJob, Azure Functions Timer Trigger, Azure Container Apps Jobs. Often the right choice — keep your web app stateless.
  • Graceful shutdown: the stopping token cancels when shutdown begins; honor it within the host's ShutdownTimeout (default 30s).
  • BackgroundServiceExceptionBehavior controls what happens if ExecuteAsync throws — modern default is StopHost.

Concepts (deep dive)

IHostedService vs BackgroundService

// IHostedService — explicit lifecycle
public class CacheWarmer(ICache c) : IHostedService
{
    public Task StartAsync(CancellationToken ct) { c.Warm(); return Task.CompletedTask; }
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

// BackgroundService — long-running ExecuteAsync
public class PollingWorker(IRepo repo, ILogger<PollingWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopping)
    {
        while (!stopping.IsCancellationRequested)
        {
            try { await repo.PollAsync(stopping); }
            catch (Exception ex) when (!stopping.IsCancellationRequested)
            {
                log.LogError(ex, "poll failed; retrying");
            }
            await Task.Delay(TimeSpan.FromSeconds(30), stopping);
        }
    }
}

IHostedService blocks startup until StartAsync returns. Keep it fast or use BackgroundService. Inside a BackgroundService, ExecuteAsync is awaited but the host doesn't wait for it to complete — it kicks off in the background.

Register both the same way:

builder.Services.AddHostedService<CacheWarmer>();
builder.Services.AddHostedService<PollingWorker>();

Lifecycle ordering

HostBuilder.Build() ────► Resolve hosted services
                          StartAsync (in registration order)
                          App accepts requests
                              ▼ (shutdown)
                          StopAsync (in REVERSE order)
                          Disposed

If service A registers before B, A starts first, B stops first. Useful when B depends on A.

Graceful loop pattern

protected override async Task ExecuteAsync(CancellationToken stopping)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));

    while (!stopping.IsCancellationRequested
           && await timer.WaitForNextTickAsync(stopping))
    {
        try
        {
            await DoWorkAsync(stopping);
        }
        catch (OperationCanceledException) { break; }
        catch (Exception ex) { _log.LogError(ex, "work failed"); }
    }
}

PeriodicTimer (.NET 6+) is the modern idiom — async-aware, cancellation-aware, no dangling threads. Don't use Timer in async hosted services; it's not async-friendly.

Scoped services in a hosted service

public class Worker(IServiceProvider sp) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        while (!stop.IsCancellationRequested)
        {
            using (var scope = sp.CreateAsyncScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
                await ProcessAsync(db, stop);
            }
            await Task.Delay(TimeSpan.FromMinutes(1), stop);
        }
    }
}

BackgroundService is a singleton — it can't directly accept scoped dependencies (DbContext, etc.). Create a scope per iteration with IServiceProvider.CreateAsyncScope(). The scope disposes after using, releasing scoped services.

Quartz.NET — cron-style scheduling in-process

builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("dailyReport");
    q.AddJob<DailyReportJob>(opts => opts.WithIdentity(jobKey));
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("dailyReportTrigger")
        .WithCronSchedule("0 0 8 * * ?"));   // 8 AM daily
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);

Quartz handles cron expressions, persistence (DB-backed), clustering (multi-instance coordination), priorities, and retries. Use when: - You need cron expressions. - Multiple instances must coordinate (only one runs each scheduled job). - Job state must survive restarts.

Hangfire — persisted jobs with dashboard

builder.Services.AddHangfire(c => c.UseSqlServerStorage(connStr));
builder.Services.AddHangfireServer();

// Enqueue a fire-and-forget job:
BackgroundJob.Enqueue<IEmailSender>(s => s.SendAsync(to, subject, body));

// Schedule:
BackgroundJob.Schedule<IEmailSender>(s => s.SendAsync(...), TimeSpan.FromMinutes(10));

// Recurring:
RecurringJob.AddOrUpdate<IReportService>(
    "daily-report",
    s => s.GenerateAsync(),
    Cron.Daily);

// Dashboard:
app.UseHangfireDashboard("/hangfire");

Hangfire stores job state in SQL Server / PostgreSQL / Redis. Includes a dashboard UI for monitoring. Best for: - Background work where the enqueue site is a controller (web request triggers a job). - Retries + persistence are critical. - You want a visible dashboard.

When to skip in-process scheduling

For true scheduled jobs (nightly batches, weekly reports), in-process is often the wrong choice. Reasons:

  • Scaling: if you scale your web app to 5 replicas, you'll run the job 5 times unless you coordinate (Quartz clustering).
  • Coupling: web app and job competition for resources.
  • Restart cycles: deployments restart the job mid-execution.

Out-of-process alternatives:

  • Kubernetes CronJob — schedules a separate pod to run on a cron expression.
  • Azure Functions Timer Trigger — serverless cron.
  • Azure Container Apps Jobs — dedicated job containers.
  • Azure Logic Apps — visual workflow orchestrator.

Web app stays stateless and simple; jobs run in their own runtime.

Cron expression cheat sheet

┌───────────── seconds (0-59)         (Quartz adds this leading field)
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31)
│ │ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌───────────── day of week (0-6 or SUN-SAT; * or ?)
│ │ │ │ │ │
0 0 8 * * ?     # Quartz: 8 AM every day
  0 8 * * *     # Hangfire/k8s standard: 8 AM every day
  */15 * * * *  # every 15 minutes

Quartz uses 6-7 fields (with seconds + optional year); Hangfire/k8s use 5 (no seconds). Sanity-check with crontab.guru before deploying.

Channels for in-process work queues

public class WorkQueue(Channel<WorkItem> channel) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        await foreach (var item in channel.Reader.ReadAllAsync(stop))
        {
            try { await ProcessAsync(item, stop); }
            catch (Exception ex) { /* log */ }
        }
    }
}

builder.Services.AddSingleton(_ => Channel.CreateBounded<WorkItem>(1000));
builder.Services.AddHostedService<WorkQueue>();

// Producer (controller, etc.):
public class OrderController(Channel<WorkItem> q) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Place(...)
    {
        await q.Writer.WriteAsync(workItem);
        return Accepted();
    }
}

Pattern: HTTP request enqueues; background service processes. Doesn't survive process restarts. Use Hangfire / Service Bus / Storage Queue for persistence.


Code: correct vs wrong

❌ Wrong: scoped service injected directly

public class Worker(MyDbContext db) : BackgroundService   // ❌ DbContext is scoped
{
    /* container can't resolve scoped into singleton */
}

✅ Correct: scope per iteration

public class Worker(IServiceProvider sp) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        using var scope = sp.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        /* ... */
    }
}

❌ Wrong: Thread.Sleep in ExecuteAsync

while (!stop.IsCancellationRequested)
{
    DoWork();
    Thread.Sleep(60_000);   // ❌ blocks threadpool thread; ignores cancellation
}

✅ Correct: Task.Delay with cancellation

while (!stop.IsCancellationRequested)
{
    DoWork();
    try { await Task.Delay(TimeSpan.FromMinutes(1), stop); }
    catch (OperationCanceledException) { break; }
}

❌ Wrong: catching Exception and continuing without considering cancellation

while (!stop.IsCancellationRequested)
{
    try { await DoAsync(stop); }
    catch (Exception ex) { _log.LogError(ex); }   // ❌ swallows OperationCanceledException
}

✅ Correct: re-raise on cancellation

while (!stop.IsCancellationRequested)
{
    try { await DoAsync(stop); }
    catch (OperationCanceledException) when (stop.IsCancellationRequested) { break; }
    catch (Exception ex) { _log.LogError(ex); }
}

Design patterns for this topic

Pattern 1 — "PeriodicTimer for fixed-interval polling"

  • Intent: alloc-free, cancel-aware periodic work.

Pattern 2 — "Scope per iteration for scoped services"

  • Intent: access DbContext / scoped services from a singleton hosted service.

Pattern 3 — "Channel as in-process queue"

  • Intent: HTTP-to-worker handoff without external broker.

Pattern 4 — "Out-of-process scheduling for cron"

  • Intent: keep web app stateless; scale schedule independently.

Pattern 5 — "BackgroundServiceExceptionBehavior.StopHost for fail-loud"

  • Intent: unhandled errors terminate the host so orchestrator restarts.

Pros & cons / trade-offs

Approach Pros Cons
IHostedService Standard Blocks startup
BackgroundService Non-blocking; idiomatic Need scope-per-iteration for scoped deps
Quartz Cron + clustering + persistence Heavier setup
Hangfire Persistence + dashboard DB dependency
K8s CronJob Separate pod; simple Cold-start per run
Azure Functions Serverless; cheap idle Vendor lock-in
Channel-based Simple in-process No persistence

When to use / when to avoid

  • Use BackgroundService for in-process long-running loops.
  • Use Quartz when you need cron + clustering + persistence in-process.
  • Use Hangfire when you need a job dashboard + retries + persistence.
  • Use external schedulers (k8s CronJob, Functions Timer) for production cron.
  • Avoid IHostedService.StartAsync for slow work.
  • Avoid Thread.Sleep — use Task.Delay.

Interview Q&A

Q1. Difference between IHostedService and BackgroundService? BackgroundService extends IHostedService with ExecuteAsync for long-running loops; the host doesn't wait for ExecuteAsync to return.

Q2. How do you use scoped services in a singleton hosted service? Inject IServiceProvider and create a scope per unit of work via CreateAsyncScope(). Resolve scoped services from the scope.

Q3. What's BackgroundServiceExceptionBehavior? Controls behavior when ExecuteAsync throws. Options: Ignore (log only), StopHost (terminate). Default in modern .NET: StopHost.

Q4. When would you choose Quartz over Hangfire? Quartz when you need cron expressions and clustering. Hangfire when you need a dashboard, retries, persistent enqueue from web requests.

Q5. How do you ensure a scheduled job runs only once across replicas? Quartz clustering, Hangfire's distributed lock, or out-of-process scheduling (k8s CronJob — single pod per cron firing).

Q6. Why prefer PeriodicTimer over Task.Delay in loops? PeriodicTimer.WaitForNextTickAsync accounts for time spent in the loop body, drifting less. Cleaner cancellation handling.

Q7. What happens if ExecuteAsync returns early? Depends on BackgroundServiceExceptionBehavior. By default in modern .NET, the host treats early exit (with or without exception) as fatal and stops.

Q8. How do you signal completion from a BackgroundService? The Task returned from ExecuteAsync represents lifetime. Returning normally completes the service (host stops it on shutdown).

Q9. Where would you handle exceptions in a BackgroundService? Inside the loop — log and decide whether to break or continue. Letting an exception propagate out of ExecuteAsync triggers the configured exception behavior.

Q10. Why is Thread.Sleep dangerous in async hosted services? Blocks a threadpool thread without honoring cancellation. Use Task.Delay(timespan, ct).


Gotchas / common mistakes

  • ⚠️ Scoped service in singleton constructorDI container error.
  • ⚠️ Catching Exception without considering OperationCanceledException — service won't stop.
  • ⚠️ Thread.Sleep — wastes thread; ignores cancel.
  • ⚠️ Long Task.Delay without cancellation — slow shutdown.
  • ⚠️ In-process cron in multi-replica deployments — fires N times.
  • ⚠️ Hosted service order assumed by registration — document; add explicit health checks.

Further reading