IHostedService defines StartAsync/StopAsync; BackgroundService provides ExecuteAsync with automatic fire-and-forget
Use IHostedService for one-shot startup tasks (cache warm, DB migration) that block Kestrel until complete
Use BackgroundService for long-running loops; StartAsync returns immediately so your app starts fast
Always inject IServiceScopeFactory, never scoped services — a hosted service is a singleton
Task.Delay(interval, stoppingToken) is mandatory; omitting the token causes multi-second deployment hangs
Unhandled exceptions in ExecuteAsync silently kill your worker in .NET 6+ — wrap the loop in try/catch or crash intentionally with StopApplication()
Plain-English First
Imagine a busy restaurant. The waiters serve customers out front — that's your web app handling HTTP requests. But in the kitchen, a chef is quietly prepping ingredients, cleaning equipment, and restocking supplies whether or not any customer is sitting at a table. Background Services in ASP.NET Core are that kitchen crew. They run silently in the background, doing work your app needs done — sending emails, processing queues, cleaning old data — without a customer ever having to ask.
Every non-trivial web application eventually needs to do work that no HTTP request triggers. Think about it: who sends the 'your order has shipped' email at 2am? Who cleans up expired sessions from your database? Who polls a third-party API every 30 seconds for price updates? If your answer is 'a separate console app' or 'a Windows Service', you're managing two deployment artifacts instead of one — and introducing a whole class of synchronization headaches. ASP.NET Core's hosted service model was built to solve exactly this.
Before ASP.NET Core 2.1, developers stitched together timers, threads, and Application_Start hacks to get background work done inside an ASP.NET process. It was fragile, leaked resources on shutdown, and had zero first-class support from the DI container or the application lifetime. The IHostedService interface and the BackgroundService base class changed the game by making background work a first-class citizen — with proper startup/shutdown coordination, cancellation token support, and full access to the DI container.
By the end of this article you'll be able to implement both timed background jobs and queue-consuming workers in production-quality code. You'll understand the difference between IHostedService and BackgroundService, why scoped services inside a singleton hosted service will silently give you stale data or worse, how to handle exceptions without silently killing your background loop, and exactly how the .NET Generic Host coordinates shutdown across all hosted services. Let's build it layer by layer.
IHostedService — The Contract That Everything Builds On
IHostedService is a two-method interface defined in Microsoft.Extensions.Hosting. That's it — StartAsync(CancellationToken) and StopAsync(CancellationToken). The Generic Host calls StartAsync on every registered IHostedService in registration order during startup, and StopAsync in reverse order during shutdown. This order guarantee is load-bearing — if Service B depends on Service A being ready, register A first.
StartAsync is called before the HTTP server starts accepting requests in a web application. This is intentional: if your background service needs to warm a cache before traffic hits, you can do it here and the host will wait. But watch out — if StartAsync blocks indefinitely, your app never starts. Long-running work should be kicked off onto a Task and returned from immediately, not awaited inline.
StopAsync receives a cancellation token with a configurable timeout (default 5 seconds, controlled by HostOptions.ShutdownTimeout). When SIGTERM arrives — whether from Kubernetes, a dotnet stop, or Ctrl+C — the host signals this token. Your service has until the timeout to finish gracefully. After that, the process is terminated regardless. This is why your background loops must observe cancellation tokens religiously, not just at the top level.
CacheWarmingService.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
usingMicrosoft.Extensions.Hosting;
usingMicrosoft.Extensions.Logging;
usingSystem;
usingSystem.Threading;
usingSystem.Threading.Tasks;
/// <summary>/// Warms a local in-memory cache before the HTTP server accepts any traffic./// Because StartAsync is awaited by the host before Kestrel starts, requests/// will never see a cold cache state./// </summary>publicsealedclassCacheWarmingService : IHostedService
{
privatereadonlyIProductCacheService _productCache;
privatereadonlyILogger<CacheWarmingService> _logger;
publicCacheWarmingService(
IProductCacheService productCache,
ILogger<CacheWarmingService> logger)
{
_productCache = productCache;
_logger = logger;
}
// Called by the host before the HTTP server starts.// We AWAIT the cache warm here intentionally — we want it complete before traffic arrives.publicasyncTaskStartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[CacheWarmingService] Warming product cache before accepting traffic...");
// Pass cancellationToken down so we can abort if the host is shutting down// before we even finish starting (e.g., rapid Ctrl+C during startup).await _productCache.WarmAsync(cancellationToken);
_logger.LogInformation("[CacheWarmingService] Cache warm complete. Ready for traffic.");
}
// Called by the host when shutdown is signalled.// Nothing to clean up here — the cache service handles its own disposal.publicTaskStopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[CacheWarmingService] Stopping — no cleanup required.");
returnTask.CompletedTask;
}
}
// --- Registration in Program.cs ---// builder.Services.AddHostedService<CacheWarmingService>();// builder.Services.AddSingleton<IProductCacheService, ProductCacheService>();
Output
info: CacheWarmingService[0]
[CacheWarmingService] Warming product cache before accepting traffic...
info: CacheWarmingService[0]
[CacheWarmingService] Cache warm complete. Ready for traffic.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:5001
Watch Out: Never Block StartAsync
If you await a long-running loop directly inside StartAsync, Kestrel never starts — your app hangs at launch with no error. The pattern is: start a Task with Task.Run or store it as a private field, then return from StartAsync immediately. BackgroundService handles this pattern for you automatically, which is why you should prefer it for long-running work.
Production Insight
CacheWarmingService blocked StartAsync for 45 seconds once — Kestrel never started, health checks failed, Kubernetes killed the pod.
Rule: For one-shot startup tasks, awaiting is fine. Never start a long-running loop in StartAsync without delegating to a background Task.
If your warm-up depends on external services, add a timeout so the app starts even if they're down.
Use it for one-shot tasks that must complete before traffic arrives.
Do NOT place infinite loops in StartAsync — use BackgroundService instead.
Choose Between IHostedService and BackgroundService
IfOne-shot startup task that must complete before HTTP server starts (cache warm, DB migration)
→
UseImplement IHostedService directly — await the work inside StartAsync. Kestrel waits.
IfLong-running loop that polls a queue, sends emails, or processes batches
→
UseUse BackgroundService — StartAsync returns immediately, ExecuteAsync runs on a background task.
IfShort-lived work that needs to happen once after startup but not block
→
UseUse IHostedService but start a fire-and-forget task inside StartAsync. Better: use BackgroundService and break after first iteration.
BackgroundService — The Right Way to Write Long-Running Workers
BackgroundService is an abstract base class that implements IHostedService for you. It introduces a single abstract method: ExecuteAsync(CancellationToken stoppingToken). The base class's StartAsync implementation kicks ExecuteAsync off on a background Task and returns immediately — solving the 'don't block StartAsync' problem without you having to think about it.
The stoppingToken passed into ExecuteAsync is cancelled when the host begins its shutdown sequence. Your job is to observe that token inside your loop. The idiomatic pattern is a while (!stoppingToken.IsCancellationRequested) loop, or passing the token to every awaitable operation you call. If ExecuteAsync throws an unhandled exception, in .NET 6+ the default behaviour is to log the exception and stop the hosted service — but critically, the host process keeps running. This means your background job silently dies while your web app happily continues serving requests. We'll cover how to fix this in the gotchas section.
One subtlety that catches people out:StopAsync in BackgroundService cancels stoppingToken and then awaits the ExecuteAsync task. If your loop doesn't observe the cancellation token, StopAsync will block until HostOptions.ShutdownTimeout expires and then the process is forcibly killed — your 'graceful shutdown' isn't graceful at all.
OrderProcessingWorker.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
usingMicrosoft.Extensions.DependencyInjection;
usingMicrosoft.Extensions.Hosting;
usingMicrosoft.Extensions.Logging;
usingSystem;
usingSystem.Threading;
usingSystem.Threading.Tasks;
/// <summary>/// Polls an order queue every 5 seconds and processes any pending orders./// Demonstrates the correct BackgroundService pattern including:/// - Scoped service resolution inside a singleton worker/// - Cancellation token propagation/// - Exception handling that keeps the loop alive/// </summary>publicsealedclassOrderProcessingWorker : BackgroundService
{
// We inject IServiceScopeFactory — NOT IOrderRepository directly.// BackgroundService is registered as a singleton, but IOrderRepository// is likely scoped. Injecting a scoped service into a singleton causes// the 'captured dependency' bug. IServiceScopeFactory is always safe.privatereadonlyIServiceScopeFactory _scopeFactory;
privatereadonlyILogger<OrderProcessingWorker> _logger;
privatestaticreadonlyTimeSpanPollingInterval = TimeSpan.FromSeconds(5);
publicOrderProcessingWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
// ExecuteAsync is called once by BackgroundService.StartAsync on a background Task.// It runs until stoppingToken is cancelled (host shutdown) or an exception escapes.protectedoverrideasyncTaskExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[OrderProcessingWorker] Worker started.");
// Loop runs until the host signals shutdown via stoppingToken.while (!stoppingToken.IsCancellationRequested)
{
try
{
awaitProcessPendingOrdersAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// This is normal — stoppingToken was cancelled during an await.// Break the loop cleanly rather than logging a spurious error.
_logger.LogInformation("[OrderProcessingWorker] Shutdown requested during processing.");
break;
}
catch (Exception ex)
{
// Log the error but DON'T rethrow — rethrowing kills the hosted service.// Instead, we pause briefly and retry on the next iteration.// In production you'd also want alerting here (Sentry, Application Insights, etc.).
_logger.LogError(ex, "[OrderProcessingWorker] Unhandled exception in processing loop. Retrying in {Interval}s.", PollingInterval.TotalSeconds);
}
// Task.Delay observes the cancellation token — if shutdown happens during// the delay, it throws OperationCanceledException immediately rather// than waiting out the full interval. This is what makes shutdown fast.awaitTask.Delay(PollingInterval, stoppingToken);
}
_logger.LogInformation("[OrderProcessingWorker] Worker stopped cleanly.");
}
privateasyncTaskProcessPendingOrdersAsync(CancellationToken cancellationToken)
{
// Create a fresh DI scope per iteration — this gives us a fresh DbContext,// fresh unit-of-work, etc. Scope is disposed at end of using block.awaitusingvar scope = _scopeFactory.CreateAsyncScope();
var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var orderNotifier = scope.ServiceProvider.GetRequiredService<IOrderNotifier>();
var pendingOrders = await orderRepository.GetPendingOrdersAsync(cancellationToken);
if (pendingOrders.Count == 0)
{
_logger.LogDebug("[OrderProcessingWorker] No pending orders found.");
return;
}
_logger.LogInformation("[OrderProcessingWorker] Processing {Count} pending orders.", pendingOrders.Count);
foreach (var order in pendingOrders)
{
// Pass cancellationToken to every async call so we can abort mid-batch on shutdown.await orderRepository.MarkAsProcessingAsync(order.Id, cancellationToken);
await orderNotifier.SendConfirmationAsync(order, cancellationToken);
await orderRepository.MarkAsCompleteAsync(order.Id, cancellationToken);
_logger.LogInformation("[OrderProcessingWorker] Order {OrderId} processed successfully.", order.Id);
}
}
}
// --- Registration in Program.cs ---// builder.Services.AddHostedService<OrderProcessingWorker>();
[OrderProcessingWorker] Order a1b2c3 processed successfully.
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Order d4e5f6 processed successfully.
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Order g7h8i9 processed successfully.
# ... on Ctrl+C ...
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Shutdown requested during processing.
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Worker stopped cleanly.
Pro Tip: Use Task.Delay With the Cancellation Token
Always write await Task.Delay(interval, stoppingToken) — never await Task.Delay(interval). The token-free overload means your worker will sleep through a shutdown signal and the process won't terminate until the delay expires. With a 60-second interval, that's a 60-second delay on every deployment. Kubernetes will kill your pod as 'unhealthy' long before then.
Production Insight
A team using Task.Delay(5000) without the token saw 20-second deployment delays because their worker ignored shutdown.
The fix took one parameter: Task.Delay(5000, stoppingToken).
Rule: always pass the cancellation token to any blocking or async operation that supports it.
You must still propagate the stoppingToken to every await.
Inject IServiceScopeFactory for scoped services — never inject scoped services directly.
Handle Scoped Dependencies Correctly
IfWorker needs a DbContext or other scoped service per iteration
→
UseInject IServiceScopeFactory. Create new scope with CreateAsyncScope() and resolve scoped services within the using block.
IfWorker uses a singleton service (cache, HttpClient, logger)
→
UseInject directly. No scope needed. Ensure the singleton is thread-safe if shared across iterations.
IfWorker needs a different scoped service per message (e.g., per-user tenant context)
→
UseResolve the tenant context from the scope and pass it to scoped services. Do not cache tenant data in the worker itself.
Production Patterns — Channels, Scoped Services, and Crash-Safe Workers
Polling on a timer works, but it's inefficient when you have variable load. The production pattern for background processing is a producer-consumer queue using System.Threading.Channels. Your HTTP controllers or other services write work items into the channel (non-blocking), and your BackgroundService consumes from the channel as fast as it can. This gives you genuine push-based processing with backpressure support — you can cap the channel's capacity to apply backpressure to producers when the consumer falls behind.
The second production concern is crash resilience. As mentioned, an unhandled exception in ExecuteAsync silently kills your worker in .NET 6+. The fix that most teams reach for is wrapping the entire loop body in a try/catch — which we did above. But there's a more nuclear option: setting TaskScheduler.UnobservedTaskException and/or implementing IHostApplicationLifetime.StopApplication() inside your catch block to bring the whole process down intentionally. In Kubernetes, a crashed pod restarts — a silently dead worker doesn't. Sometimes a hard crash is safer than silent failure.
For production observability, track three metrics on every background worker: iterations completed, exceptions per iteration, and processing lag (time from item enqueue to item processed). These three numbers tell you everything about the health of your worker at a glance.
EmailDispatchChannel.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
usingSystem.Threading.Channels;
usingSystem.Threading;
usingSystem.Threading.Tasks;
usingMicrosoft.Extensions.Hosting;
usingMicrosoft.Extensions.Logging;
usingMicrosoft.Extensions.DependencyInjection;
usingSystem;
/// <summary>/// Represents a single email dispatch request written by HTTP handlers/// and consumed by the background worker./// </summary>publicsealed record EmailDispatchRequest(
stringRecipientAddress,
stringSubject,
stringHtmlBody,
DateTimeOffsetEnqueuedAt);
/// <summary>/// Singleton channel that acts as the in-process message bus between/// HTTP request handlers (producers) and the email worker (consumer)./// Registered as a singleton so both producers and the worker share the same instance./// </summary>publicsealedclassEmailDispatchChannel
{
// BoundedCapacity of 500 means the channel holds at most 500 pending emails.// If the worker falls behind, WriteAsync on producers will apply backpressure// (await until space is available) rather than silently dropping messages.privatereadonlyChannel<EmailDispatchRequest> _channel = Channel.CreateBounded<EmailDispatchRequest>(
newBoundedChannelOptions(capacity: 500)
{
FullMode = BoundedChannelFullMode.Wait, // Block producers rather than dropSingleReader = true, // Only the worker reads — allows optimizationsSingleWriter = false // Many HTTP request threads can write
});
publicChannelWriter<EmailDispatchRequest> Writer => _channel.Writer;
publicChannelReader<EmailDispatchRequest> Reader => _channel.Reader;
}
/// <summary>/// Background worker that consumes email dispatch requests from the channel./// Uses ChannelReader.ReadAllAsync which is the cleanest cancellation-aware/// consume pattern — it stops iteration automatically when the channel is/// completed OR the cancellation token is fired./// </summary>publicsealedclassEmailDispatchWorker : BackgroundService
{
privatereadonlyEmailDispatchChannel _emailChannel;
privatereadonlyIServiceScopeFactory _scopeFactory;
privatereadonlyIHostApplicationLifetime _appLifetime;
privatereadonlyILogger<EmailDispatchWorker> _logger;
publicEmailDispatchWorker(
EmailDispatchChannel emailChannel,
IServiceScopeFactory scopeFactory,
IHostApplicationLifetime appLifetime,
ILogger<EmailDispatchWorker> logger)
{
_emailChannel = emailChannel;
_scopeFactory = scopeFactory;
_appLifetime = appLifetime;
_logger = logger;
}
protectedoverrideasyncTaskExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[EmailDispatchWorker] Starting — listening for dispatch requests.");
// ReadAllAsync yields each item as it arrives, blocking asynchronously// when the channel is empty. When stoppingToken fires, the IAsyncEnumerable// stops yielding and the loop exits cleanly.awaitforeach (var request in _emailChannel.Reader.ReadAllAsync(stoppingToken))
{
var lag = DateTimeOffset.UtcNow - request.EnqueuedAt;
// Alert if emails are sitting in the queue for more than 30 seconds.if (lag > TimeSpan.FromSeconds(30))
{
_logger.LogWarning(
"[EmailDispatchWorker] High lag detected: {Lag:F1}s for email to {Recipient}.",
lag.TotalSeconds, request.RecipientAddress);
}
try
{
awaitDispatchEmailAsync(request, stoppingToken);
_logger.LogInformation(
"[EmailDispatchWorker] Email dispatched to {Recipient} (lag: {Lag:F1}s).",
request.RecipientAddress, lag.TotalSeconds);
}
catch (OperationCanceledException)
{
// Shutdown during dispatch — requeue or accept the loss depending on your SLA.
_logger.LogWarning(
"[EmailDispatchWorker] Cancelled mid-dispatch for {Recipient}. Item may be lost.",
request.RecipientAddress);
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"[EmailDispatchWorker] Failed to dispatch email to {Recipient}. Continuing with next item.",
request.RecipientAddress);
// For truly critical workers: call _appLifetime.StopApplication() here// to crash the pod intentionally so Kubernetes restarts it.// Safer than silently skipping and accumulating failures.
}
}
_logger.LogInformation("[EmailDispatchWorker] Stopped.");
}
privateasyncTaskDispatchEmailAsync(EmailDispatchRequest request, CancellationToken cancellationToken)
{
awaitusingvar scope = _scopeFactory.CreateAsyncScope();
var emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
await emailSender.SendAsync(request.RecipientAddress, request.Subject, request.HtmlBody, cancellationToken);
}
}
// --- Registration in Program.cs ---// builder.Services.AddSingleton<EmailDispatchChannel>();// builder.Services.AddHostedService<EmailDispatchWorker>();//// --- Usage in a Controller or Minimal API endpoint ---// await emailChannel.Writer.WriteAsync(new EmailDispatchRequest(// RecipientAddress: "user@example.com",// Subject: "Your order is confirmed",// HtmlBody: "<h1>Thanks for your order!</h1>",// EnqueuedAt: DateTimeOffset.UtcNow), cancellationToken);
Output
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Starting — listening for dispatch requests.
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Email dispatched to alice@example.com (lag: 0.1s).
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Email dispatched to bob@example.com (lag: 0.2s).
warn: EmailDispatchWorker[0]
[EmailDispatchWorker] High lag detected: 34.7s for email to charlie@example.com.
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Email dispatched to charlie@example.com (lag: 34.7s).
# ... on graceful shutdown ...
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Stopped.
Interview Gold: Channel vs. ConcurrentQueue
ConcurrentQueue<T> requires a polling loop with Thread.Sleep or Task.Delay to check for new items — wasting CPU cycles and adding latency. Channel<T> is a true async signalling primitive: ReadAllAsync suspends with zero CPU cost when the channel is empty and resumes the instant an item is written. For background workers, Channel is almost always the right choice over ConcurrentQueue.
Production Insight
A team used ConcurrentQueue polled every 100ms — CPU spiked to 30% from the spin-wait even when queue was empty.
Replaced with Channel<T> using ReadAllAsync — CPU dropped to near zero under idle.
Rule: for push-based consumption, use Channel<T>. For periodic tasks, use BackgroundService with Task.Delay.
Use BoundedChannel with Wait for producers to avoid message loss.
Always pass cancellation token to ReadAllAsync for instant shutdown.
Choose Queueing Strategy for Background Workers
IfProducer-consumer pattern with variable load; want backpressure and no polling overhead
→
UseUse Channel<T> with BoundedChannelOptions. Set capacity based on max acceptable lag.
IfSimple periodic work that runs every N seconds regardless of workload
→
UseUse BackgroundService with Task.Delay. No queue needed.
IfWork items come from external message broker (RabbitMQ, Kafka, Service Bus)
→
UseUse the broker's client library directly. Channels are for in-process exchange only.
The Generic Host Shutdown Sequence — What Actually Happens at Ctrl+C
Understanding shutdown is what separates production-grade background services from ones that corrupt data on every deployment. When the host receives a termination signal (SIGTERM on Linux, Ctrl+C, or IHostApplicationLifetime.StopApplication()), here's the exact sequence:
First, IHostApplicationLifetime.ApplicationStopping fires — useful for stopping new work from being accepted. Second, IHostedService.StopAsync is called on all hosted services in reverse registration order, and all calls run concurrently. Third, the host waits up to HostOptions.ShutdownTimeout (default 5 seconds) for all StopAsync calls to complete. Fourth, IHostApplicationLifetime.ApplicationStopped fires and the process exits.
That 5-second default is almost never enough for a real worker that might be mid-batch on a database transaction. In production, increase it: builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(30)). In Kubernetes, set your pod's terminationGracePeriodSeconds to match. If your .NET shutdown timeout is 30s but Kubernetes kills the pod after 20s, you've still got a problem.
Also be aware that StopAsync is called concurrently across all services — which means if your CacheWarmingService and OrderProcessingWorker both need the database connection during shutdown, they may race. Design your StopAsync implementations to be independent.
Program.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
usingMicrosoft.Extensions.Hosting;
usingMicrosoft.Extensions.DependencyInjection;
usingSystem;
var builder = WebApplication.CreateBuilder(args);
// --- Configure shutdown timeout to 30 seconds ---// Default is 5 seconds — almost always too short for real workers.// Match this value to your Kubernetes terminationGracePeriodSeconds minus a 5s buffer.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
// --- Register infrastructure services ---
builder.Services.AddSingleton<IProductCacheService, ProductCacheService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderNotifier, OrderNotifier>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<EmailDispatchChannel>();
// --- Register hosted services in dependency order ---// CacheWarmingService runs first (StartAsync blocks until cache is warm)// before any worker that might query the cache.
builder.Services.AddHostedService<CacheWarmingService>();
builder.Services.AddHostedService<OrderProcessingWorker>();
builder.Services.AddHostedService<EmailDispatchWorker>();
// --- Add a hosted service that monitors other services and restarts them ---// Pattern: inject IHostApplicationLifetime to self-heal or escalate crashes.
builder.Services.AddHostedService<WorkerHealthMonitor>();
builder.Services.AddControllers();
var app = builder.Build();
// Register a callback on ApplicationStopping to flush any in-flight telemetry// before the process exits. This runs before StopAsync on any hosted service.var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
app.Logger.LogInformation("[Host] Shutdown initiated — flushing telemetry..."));
app.MapControllers();
app.Run();
// --- WorkerHealthMonitor: a self-healing pattern ---// Demonstrates using IHostApplicationLifetime to crash intentionally on unrecoverable failure.publicsealedclassWorkerHealthMonitor : BackgroundService
{
privatereadonlyIHostApplicationLifetime _appLifetime;
privatereadonlyILogger<WorkerHealthMonitor> _logger;
publicWorkerHealthMonitor(
IHostApplicationLifetime appLifetime,
ILogger<WorkerHealthMonitor> logger)
{
_appLifetime = appLifetime;
_logger = logger;
}
protectedoverrideasyncTaskExecuteAsync(CancellationToken stoppingToken)
{
// Listen for the application started event before beginning health checks.awaitTask.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
// In a real implementation, check metrics endpoints, event counters,// or a shared health flag set by other workers.bool systemHealthy = awaitCheckSystemHealthAsync(stoppingToken);
if (!systemHealthy)
{
_logger.LogCritical(
"[WorkerHealthMonitor] Critical subsystem failure detected. Initiating controlled shutdown.");
// StopApplication triggers graceful shutdown — all StopAsync methods run,// ShutdownTimeout is respected, and the process exits cleanly.// In Kubernetes this causes a pod restart — which is what we want.
_appLifetime.StopApplication();
return;
}
awaitTask.Delay(TimeSpan.FromSeconds(15), stoppingToken);
}
}
privateTask<bool> CheckSystemHealthAsync(CancellationToken cancellationToken)
{
// Placeholder — in production, query a health check endpoint or// check a shared failure counter from other workers.returnTask.FromResult(true);
}
}
Output
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
# ... on Ctrl+C ...
info: Host[0]
[Host] Shutdown initiated — flushing telemetry...
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Shutdown requested during processing.
info: OrderProcessingWorker[0]
[OrderProcessingWorker] Worker stopped cleanly.
info: EmailDispatchWorker[0]
[EmailDispatchWorker] Stopped.
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
Watch Out: Kubernetes vs. .NET Shutdown Timeout Mismatch
If terminationGracePeriodSeconds in your Kubernetes pod spec is 30s but your .NET ShutdownTimeout is the default 5s, your workers get brutally killed at 5s and Kubernetes keeps waiting until 30s regardless. Set your .NET ShutdownTimeout to terminationGracePeriodSeconds - 5 seconds to give yourself a buffer. The 5-second gap allows the host to cleanly log shutdown before the pod is force-killed.
Production Insight
We had a worker that persisted in-flight transactions during shutdown — 5 seconds wasn't enough for a batch of 200 records.
Increased ShutdownTimeout to 60s, matched Kubernetes terminationGracePeriodSeconds to 65s.
Rule: always set ShutdownTimeout explicitly in production. Never rely on the 5-second default.
Key Takeaway
Default ShutdownTimeout is 5s — too short for any real worker.
Set to 30-60s and match Kubernetes terminationGracePeriodSeconds.
StopAsync runs concurrently across services — watch for resource contention during shutdown.
Coordinate .NET and Kubernetes Shutdown Timings
IfRunning in Kubernetes with default shutdown settings
→
UseSet ShutdownTimeout to terminationGracePeriodSeconds - 5 seconds. Default 5s will cause force-kills.
IfDeploying to a single server with manual stop
→
UseShutdownTimeout can be shorter (10-15s). Ensure workers observe the token to exit quickly.
IfWorker performs idempotent operations that can be retried safely
Worker Services and the Generic Host — When to Use a Separate Process
The .NET Worker Service template creates a Generic Host with no HTTP server — a pure background process. It uses the same BackgroundService base class, same DI container, same shutdown sequence. Everything you've learned applies identically. The difference is deployment topology: a Worker Service runs as a separate container in your Kubernetes cluster, while an AddHostedService<T> lives inside your web application's process.
When should you split? If the background work is CPU-intensive, has different scaling requirements, or consumes a different memory profile than your web API — split it. A web API that also processes a 100MB image upload queue will have unpredictable memory pressure. A separate worker process can scale independently based on queue depth. Use a message broker (Azure Service Bus, RabbitMQ, Kafka) to bridge the two.
If the background work is I/O bound, lightweight, and shares data with the web layer (e.g., an in-memory cache warming service), keep it in-process. The cost of serialising data over a network far outweighs the isolation benefit. The rule of thumb: if you can express the background work as a few async operations that don't block the thread pool, keep it co-hosted. If you need to allocate large amounts of memory or maintain long-running connections, give it its own process.
Worker.cs (Worker Service Template)CSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
usingMicrosoft.Extensions.Hosting;
usingMicrosoft.Extensions.Logging;
usingSystem;
usingSystem.Threading;
usingSystem.Threading.Tasks;
// This is the default Worker.cs from the .NET Worker Service template.// It's a pure BackgroundService — identical to what you'd write inside a web app.// The only difference is no Kestrel, no HTTP middleware.publicsealedclassWorker : BackgroundService
{
privatereadonlyILogger<Worker> _logger;
publicWorker(ILogger<Worker> logger)
{
_logger = logger;
}
protectedoverrideasyncTaskExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Worker running at: {Time}", DateTimeOffset.Now);
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker doing work at: {Time}", DateTimeOffset.Now);
awaitTask.Delay(1000, stoppingToken);
}
_logger.LogInformation("Worker stopped at: {Time}", DateTimeOffset.Now);
}
}
// --- Program.cs for Worker Service ---usingMicrosoft.Extensions.DependencyInjection;
usingMicrosoft.Extensions.Hosting;
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices((hostContext, services) =>
{
// Register dependencies exactly as you would in a web app
services.AddSingleton<IEmailSender, SmtpEmailSender>();
services.AddHostedService<Worker>();
});
var host = builder.Build();
await host.RunAsync();
Output
info: Worker[0]
Worker running at: 04/22/2026 14:00:00
info: Worker[0]
Worker doing work at: 04/22/2026 14:00:01
info: Worker[0]
Worker doing work at: 04/22/2026 14:00:02
# ... on Ctrl+C ...
info: Worker[0]
Worker stopped at: 04/22/2026 14:00:03
When to Split? Watch the Thread Pool
If your background worker uses a lot of thread pool threads (e.g., parallel image processing), it can starve your web API of threads — even with async I/O. In that case, split into a separate Worker Service. Conversely, if your worker is mostly awaiting I/O (DB calls, HTTP requests), it barely uses threads and is safe to co-host.
Production Insight
A payment processing service co-hosted with the web API caused intermittent timeouts during peak load — the worker's parallel batch processing consumed all thread pool threads.
Split into a separate Worker Service with its own scaling policy — timeouts vanished.
Rule: monitor thread pool queue length. If it spikes when the worker runs, split the processes.
Key Takeaway
Worker Service = same BackgroundService, no HTTP server.
Co-host lightweight I/O workers; split CPU-intensive or memory-heavy workers.
Use a message broker to communicate between web and worker processes.
Decide: Co-host or Separate Worker Service?
IfBackground work is I/O bound, shares data with web layer (cache warm, database cleanup)
→
UseCo-host using AddHostedService. Keep deployment simple.
IfBackground work is CPU-intensive, memory-hungry, or needs independent scaling
→
UseSplit into a separate Worker Service. Use message queue to pass work items.
IfYou need different timeouts, health checks, or lifecycle policies
→
UseSplit. Each process can have its own ShutdownTimeout and resource limits.
● Production incidentPOST-MORTEMseverity: high
BackgroundWorker Silently Stops Processing Orders in Production
Symptom
Order confirmation emails stopped being sent. No obvious errors in logs except a single "Unhandled exception in ExecuteAsync" line from five days ago. The web API continued to return successful responses for order placement, but the worker never picked up the new orders.
Assumption
The team assumed .NET would restart the hosted service automatically if it crashed, like a thread pool thread. They also assumed any unhandled exception would be visible in Application Insights as a critical failure.
Root cause
In .NET 6+, when a BackgroundService's ExecuteAsync throws an unhandled exception, the host logs the exception and marks that hosted service as faulted — but the process continues running. No automatic restart occurs. The worker simply stops without any further indication. The team had not wrapped their processing loop in a try/catch and had not implemented any watchdog.
Fix
Wrap the entire ExecuteAsync loop body in a try/catch block. For critical workers, call _appLifetime.StopApplication() inside the catch to crash the process intentionally, triggering Kubernetes to restart the pod. Additionally, add a health check endpoint that reports the worker's status, and configure readiness probes to mark the pod unhealthy when the worker stops.
Key lesson
BackgroundService does not auto-restart after an exception. The host logs it once and moves on.
Always wrap long-running loops in try/catch. Log the error, back off, and either continue or crash the process.
For mission-critical workers, use IHostApplicationLifetime.StopApplication() to induce a pod restart — safer than silent silence.
Add a dedicated health check that exposes whether each background service is running. Wire it to Kubernetes readiness probes.
Production debug guideQuick symptom-to-action guide for when your worker goes dark5 entries
Symptom · 01
Background service stops processing but web app still responds 200 OK
→
Fix
Check application logs for any unhandled exception from the ExecuteAsync method. Use docker logs <container> or kubectl logs <pod> --previous to see if the worker crashed. Look for the exact timestamp when logging went silent. If no recent logs, the worker died silently.
Symptom · 02
Shutdown takes exactly 5 seconds then process is killed
→
Fix
The default ShutdownTimeout of 5 seconds is too short for workers mid-batch. Increase HostOptions.ShutdownTimeout to 30-60 seconds in Program.cs. Also check that your worker's loop actually respects the cancellation token — without it, StopAsync blocks until the timeout.
Symptom · 03
DbContext throws ObjectDisposedException after first iteration
→
Fix
You injected a scoped DbContext directly into the hosted service constructor. Hosted services are singletons, so the DbContext lives forever — and gets disposed after the first request scope. Fix: inject IServiceScopeFactory and create a new scope per iteration using CreateAsyncScope().
Symptom · 04
High latency in processing queue items, but CPU is low
→
Fix
Check if your queue consumer uses polling with Task.Delay. If the delay is too long, you're adding artificial latency. Switch to System.Threading.Channels with ReadAllAsync — it suspends with zero CPU cost and wakes instantly when an item arrives.
Symptom · 05
Application hangs for minutes when trying to stop in Kubernetes
→
Fix
Your worker is ignoring the cancellation token. Verify every awaitable call receives the stoppingToken — especially Task.Delay, database operations, and HTTP requests. If an operation doesn't accept a CancellationToken, wrap it with CancellationTokenSource.CreateLinkedTokenSource and a timeout.
★ Quick Debug Cheat Sheet for Background ServicesWhen a background worker goes silent, use these commands and checks to diagnose within minutes.
Worker stopped processing — no errors visible−
Immediate action
Check last log entry from that worker. If it ends with an unhandled exception, the worker crashed silently.
Same as BackgroundService — use IHostApplicationLifetime.StopApplication() to crash safely
Key takeaways
1
BackgroundService's StartAsync fires your ExecuteAsync on a background Task and returns immediately
Kestrel starts without waiting for your worker loop to complete. IHostedService.StartAsync blocks Kestrel startup, which is useful for one-shot warming tasks but dangerous for long-running work.
2
Always inject IServiceScopeFactory into hosted services, never scoped services directly. Create a new scope per unit of work with CreateAsyncScope()
this gives you a fresh DbContext and avoids the captured-dependency bug that causes stale data and connection leaks.
3
Task.Delay(interval, stoppingToken) is non-negotiable in production loops. Omitting the token means your worker sleeps through shutdown signals, causing multi-second deployment delays and potential force-kills from Kubernetes.
4
The .NET 6+ default behaviour of letting the host continue after a BackgroundService crashes is a silent failure trap. Use IHostApplicationLifetime.StopApplication() in your exception handler for critical workers so the process restarts and alerting fires
a crashed pod that restarts is far safer than a silently dead worker.
5
Co-host lightweight I/O workers with your web app; split CPU-intensive or memory-heavy workers into separate Worker Service processes. Use a message broker as the communication bridge between processes.
Common mistakes to avoid
3 patterns
×
Injecting a scoped service (DbCcontext, repository) directly into a hosted service constructor
Symptom
Application runs fine initially, but after the first request scope ends, the scoped service is disposed. Subsequent iterations throw ObjectDisposedException. The worker silently crashes and the web app keeps running.
Fix
Inject IServiceScopeFactory instead. Create a new scope at the start of each loop iteration using CreateAsyncScope() and resolve scoped services from that scope. Dispose the scope at the end of each iteration.
×
Not observing the cancellation token inside the loop body
Symptom
On deployment or scale-down, the application hangs for the full ShutdownTimeout (default 5s). Kubernetes then force-kills the pod, potentially corrupting in-flight data and delaying the deployment.
Fix
Pass the stoppingToken to every awaitable method inside your loop — especially Task.Delay, database calls, HTTP requests, and channel reads. If a method doesn't accept a CancellationToken, wrap it using CancellationTokenSource.CreateLinkedTokenSource with a timeout.
×
Letting an unhandled exception escape ExecuteAsync silently kill the worker
Symptom
No obvious alert. The web app continues to serve 200 OK. The only trace is a single "Unhandled exception in ExecuteAsync" log entry. Customers complain about missing emails or delayed processing days later.
Fix
Wrap the entire loop body in try/catch. Log the error with context. For critical workers, call _appLifetime.StopApplication() inside the catch to crash the process intentionally — Kubernetes will restart the pod. Alternatively, implement a health check that monitors whether the worker is still running.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What's the difference between IHostedService and BackgroundService, and ...
Q02SENIOR
How do you safely consume a scoped service — like an Entity Framework Db...
Q03SENIOR
Your BackgroundService is processing jobs from a queue. During a Kuberne...
Q01 of 03SENIOR
What's the difference between IHostedService and BackgroundService, and when would you choose one over the other?
ANSWER
IHostedService is a two-method interface (StartAsync/StopAsync) where StartAsync runs before the HTTP server starts. If you await a long operation in StartAsync, Kestrel doesn't start until it completes. BackgroundService is an abstract base class that implements IHostedService and introduces ExecuteAsync. Its StartAsync kicks off ExecuteAsync on a background task and returns immediately — so Kestrel starts without waiting. Use IHostedService for one-shot startup tasks that must complete before traffic hits (cache warm, DB migration). Use BackgroundService for long-running loops (queue consumers, polling workers).
Q02 of 03SENIOR
How do you safely consume a scoped service — like an Entity Framework DbContext — from inside a BackgroundService, and why does it need special handling?
ANSWER
Hosted services are registered as singletons. If you inject a scoped service directly into the constructor, that service is captured at startup and never released — you get a single DbContext instance used across all iterations. This leads to stale data, connection leaks, and ObjectDisposedException after the first request scope ends. The fix: inject IServiceScopeFactory (which is a singleton itself). At the start of each loop iteration, call await using var scope = _scopeFactory.CreateAsyncScope(); and then resolve scoped services from scope.ServiceProvider. The scope is disposed at the end of the iteration, giving you a fresh unit-of-work every time.
Q03 of 03SENIOR
Your BackgroundService is processing jobs from a queue. During a Kubernetes rolling deployment, how do you ensure in-flight jobs aren't lost, and what configuration changes are needed on both the .NET and Kubernetes side?
ANSWER
First, the worker loop must observe the cancellation token (stoppingToken) so it can abort gracefully. On the .NET side, increase HostOptions.ShutdownTimeout from the default 5 seconds to 30-60 seconds — match the value to terminationGracePeriodSeconds - 5 in your Kubernetes pod spec. In the catch for OperationCanceledException, don't rethrow — break the loop cleanly. For job loss prevention, use a transactional outbox pattern: write a job record to the database, update its status when processing starts, and commit the transaction. On startup, the worker can pick up any jobs with a 'processing' status and no recent heartbeat — effectively a retry. Also, set up readiness probes so Kubernetes stops routing traffic to the pod before shutdown begins, and use preStop hooks to send SIGTERM and give the worker time to complete.
01
What's the difference between IHostedService and BackgroundService, and when would you choose one over the other?
SENIOR
02
How do you safely consume a scoped service — like an Entity Framework DbContext — from inside a BackgroundService, and why does it need special handling?
SENIOR
03
Your BackgroundService is processing jobs from a queue. During a Kubernetes rolling deployment, how do you ensure in-flight jobs aren't lost, and what configuration changes are needed on both the .NET and Kubernetes side?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Can I run multiple background services in a single ASP.NET Core app?
Yes — call AddHostedService<T>() once per service type. All registered hosted services start concurrently after the host is built, with StartAsync called in registration order. There's no limit on the count, but each consumes a thread pool thread when actively executing, so monitor your thread pool pressure in high-throughput scenarios.
Was this helpful?
02
How do I stop a BackgroundService from inside the service itself?
Inject IHostApplicationLifetime and call _appLifetime.StopApplication(). This triggers the host's full graceful shutdown sequence — all hosted services get their StopAsync called, ShutdownTimeout is respected, and the process exits cleanly. This is the correct pattern when an unrecoverable error means you want a pod restart rather than silent failure.
Was this helpful?
03
What's the difference between a BackgroundService and a .NET Worker Service project template?
BackgroundService is a class you add to any .NET host — including an ASP.NET Core web app — to run background work alongside HTTP request handling. A Worker Service is a project template that creates a Generic Host without Kestrel, designed for background-only processes with no HTTP surface. Under the hood it uses the same BackgroundService base class — it's a deployment topology choice, not a different API.
Was this helpful?
04
How do I debug a BackgroundService that stops silently?
Check the application logs for any unhandled exception from ExecuteAsync. If nothing recent, the worker likely crashed. Add structured logging inside your loop with timestamps. Implement a health check that exposes whether the worker is still processing (e.g., a counter or timestamp updated each iteration). In Kubernetes, use readiness probes to detect a dead worker. Also consider adding an alert when the worker's last-activity timestamp stops updating.