Skip to content
learn

15. Hosted services and background work

An IHostedService participates in the host lifetime. BackgroundService supplies the long-running ExecuteAsync loop, but the host still owns startup, cancellation and shutdown.

Complete dependency injection and options and channels first. You should already understand service lifetimes, cancellation tokens and asynchronous streams.

The experiment starts one host, waits on explicit completion gates, processes two items, then asks the host to stop. There is no Thread.Sleep and every wait has a five-second bound.

In .NET 10, all of BackgroundService.ExecuteAsync runs on a background thread. Work that must happen synchronously before other hosted services start belongs in a constructor, StartAsync, or IHostedLifecycleService—not before the first await in ExecuteAsync.

A bounded queue is an application contract

Section titled “A bounded queue is an application contract”

The worker consumes a bounded Channel<T>. BoundedChannelFullMode.Wait applies backpressure when the queue is full. A real submission API should await WriteAsync; this small proof uses TryWrite because its capacity and two writes are fixed.

AddHostedService registers the worker as a singleton. A scoped dependency must therefore be resolved inside a fresh IServiceScope for every item. Injecting it into the worker constructor would turn a per-operation object into a captive dependency.

== The host starts one singleton worker and the queue owns the work
startup observed: True
queued results: C major@scope-1 | G major@scope-2
fresh scope per item: True
shutdown cancellation observed: True

The default .NET 10 host policy stops the application when a BackgroundService throws. The proof sets BackgroundServiceExceptionBehavior.StopHost explicitly, releases a fault gate and observes ApplicationStopping:

== A BackgroundService fault stops its host
fault requested host stop: True

Stopping the host is not recovery. Production code still needs an explicit retry, dead-letter or operator policy around work that may be safely repeated.

Terminal window
dotnet run --project code/csharp-advanced/Advanced -c Release -- l15

The checked transcript is expected/l15.txt.

  1. Replace TryWrite with an asynchronous submission API and prove a third item waits while the two-slot queue is full.
  2. Add one failure result to each work item without terminating the singleton worker.
  3. Introduce a second consumer and state which ordering guarantee is lost.
Solutions
  1. Return ValueTask from submission, await Writer.WriteAsync, and use gates to hold the first two items. Release one gate and assert the third write completes—never infer blocking from a delay.
  2. Catch the operation exception around one item, complete its TaskCompletionSource with a typed failure, and continue the read loop. Do not catch host cancellation as a work failure.
  3. Set SingleReader = false and run two workers. FIFO still governs reads, but completion order now depends on processing; add sequence numbers if downstream code needs reordering.