Background services and queues
IHostedService and BackgroundService, why work before the first await delays startup, and queueing work with a bounded Channel.
After this lesson you can
- Write a BackgroundService that stops cleanly on shutdown
- Explain why synchronous work at the top of ExecuteAsync delays startup
- Queue work with a bounded Channel and choose its full mode
- Name what happens to an unhandled exception in ExecuteAsync
A hosted service is code the host starts with the app and stops with it.
IHostedService has StartAsync and StopAsync; the BackgroundService
base class implements both and leaves you one method:
public class EmailSender(Channel<Email> queue, IServiceScopeFactory scopes,
ILogger<EmailSender> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in queue.Reader.ReadAllAsync(stoppingToken))
{
using var scope = scopes.CreateScope();
var smtp = scope.ServiceProvider.GetRequiredService<ISmtpClient>();
await smtp.SendAsync(email, stoppingToken);
}
}
}
builder.Services.AddSingleton(Channel.CreateBounded<Email>(1000));
builder.Services.AddHostedService<EmailSender>();
AddHostedService registers the service as a singleton, which is why it
opens a scope for each unit of work rather than taking scoped services
in its constructor. stoppingToken is cancelled when the host begins
shutting down; the host then waits for ExecuteAsync to finish, up to
a configurable shutdown timeout (HostOptions.ShutdownTimeout).
The queue
System.Threading.Channels is an in-process producer/consumer queue
built for this. An endpoint writes, the background service reads, and
the request returns without waiting for the slow work.
using System.Threading.Channels; public static class Solution{ public static List<string> Run() => RunAsync().GetAwaiter().GetResult(); static async Task<List<string>> RunAsync() { var log = new List<string>(); var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(10) { SingleReader = true }); // The background service: reads until the writer is completed. var consumer = Task.Run(async () => { var handled = new List<string>(); await foreach (var email in queue.Reader.ReadAllAsync()) handled.Add("sent " + email); return handled; }); // The endpoints: enqueue and return immediately. foreach (var to in new[] { "ann@x.io", "bob@x.io", "cy@x.io" }) { await queue.Writer.WriteAsync(to); log.Add("queued " + to); } queue.Writer.Complete(); // shutdown: no more writes log.AddRange(await consumer); return log; }}A bounded channel caps memory. Its FullMode decides what a write
does when the channel is full: Wait (the default; WriteAsync waits
and TryWrite returns false), DropOldest, DropNewest or
DropWrite. The second example writes three items into a real channel
of capacity 2 in each mode.
using System.Threading.Channels; public static class Solution{ public static List<string> Run() { var lines = new List<string>(); var modes = new[] { BoundedChannelFullMode.Wait, BoundedChannelFullMode.DropOldest, BoundedChannelFullMode.DropNewest, BoundedChannelFullMode.DropWrite, }; foreach (var mode in modes) { var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(2) { FullMode = mode }); var writes = new[] { "a", "b", "c" }.Select(x => x + "=" + channel.Writer.TryWrite(x)).ToList(); var left = new List<string>(); while (channel.Reader.TryRead(out var item)) left.Add(item); lines.Add($"{mode}: {string.Join(" ", writes)} -> [{string.Join(",", left)}]"); } return lines; }}Work in a channel lives in memory: a crash or a deployment loses it, so work that must not be lost belongs in a durable queue or an outbox table.
Startup runs your method until its first await
On .NET 9, BackgroundService.StartAsync calls ExecuteAsync and
returns when that method first yields. Everything before the first real
await runs synchronously, during startup, before the next hosted
service starts and before the server accepts requests. An exception
thrown there comes back from StartAsync itself, so the app fails to
start. The model below plays the host and an ExecuteAsync with a
synchronous warm-up.
public static class Solution{ static async Task ExecuteAsync(List<string> log, Task firstAwait) { log.Add("execute: warm-up runs synchronously"); await firstAwait; log.Add("execute: after the first await"); } public static List<string> Run() { var log = new List<string>(); var gate = new TaskCompletionSource(); log.Add("host: StartAsync"); var running = ExecuteAsync(log, gate.Task); log.Add("host: StartAsync returned, next service may start"); gate.SetResult(); // the awaited work completes later running.GetAwaiter().GetResult(); log.Add("host: done"); return log; }}A long synchronous warm-up at the top of ExecuteAsync therefore delays
the whole app. Start with an await, or move the blocking work into
Task.Run.
Failure and shutdown
Since .NET 6, an unhandled exception escaping ExecuteAsync stops the
host by default (BackgroundServiceExceptionBehavior.StopHost); set
HostOptions.BackgroundServiceExceptionBehavior to Ignore and the
host logs the exception and keeps running without that service. Catch
and log per item inside the loop so one bad message does not take down
the process. For periodic work, PeriodicTimer with
WaitForNextTickAsync(stoppingToken) avoids overlapping runs.
Common mistakes
- Injecting a
DbContextinto the hosted service's constructor: a captive dependency, since the service is a singleton. - Ignoring
stoppingToken. Shutdown then waits for the timeout and the work is cut off anyway. - Running a scheduled job in a hosted service on every instance of a scaled-out app. Each instance runs it, so the job runs N times unless something coordinates them.
- Treating
DropWriteas safe.TryWritestill returns true, so the caller never learns the item was discarded.
What interviewers ask
- A queue (Channel) plus a ; durability trade-offs; never with request-scoped services.
Exercise · Pro
Trace the host's startup
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade