Skip to content
.N
.NET // October 15, 2026 · 15 min read

How Graceful Shutdown Really Works in the .NET Generic Host and Why Containers Break It

EC
Ethan Cole
// contributor
How Graceful Shutdown Really Works in the .NET Generic Host and Why Containers Break It

Most .NET services are written as if they will run forever. In production they are stopped constantly: deployments roll pods, autoscalers remove instances, nodes are drained for maintenance, and containers are restarted after configuration changes. Every one of those events ends with the same question. When the process is told to stop, what happens to the request being processed, the message half-handled by a background worker and the batch waiting to be flushed to a database?

The .NET Generic Host has a well-defined shutdown sequence, and ASP.NET Core builds on it. But the sequence depends on several timeouts and ordering rules that are rarely visible in application code, and it interacts with container orchestrators that run their own clocks. Services that look correct in local testing can lose work, return errors to clients or be killed mid-operation as soon as they run under Kubernetes or Docker.

This article traces what the host actually does from the moment a stop signal arrives, using the current source of Microsoft.Extensions.Hosting and the documented behaviour of ASP.NET Core. It then shows where the sequence fails in containers and how to design background services and timeouts that survive real deployments. Version notes refer to .NET 10, the current long-term support release as of October 2026; .NET 8 and .NET 9 both reach end of support on 10 November 2026.

How Graceful Shutdown Really Works in the .NET Generic Host and Why Containers Break It

How a stop request reaches the host

A .NET application using the Generic Host, whether a worker service or an ASP.NET Core app built with WebApplication, runs inside an IHost. The host coordinates startup and shutdown of every IHostedService, including the web server itself.

On Linux and macOS, the default ConsoleLifetime registers handlers for three POSIX signals through PosixSignalRegistration: SIGINT, SIGQUIT and SIGTERM. In the current source, each handler sets the signal context’s Cancel property to true, which tells the runtime not to terminate the process immediately, and then calls IHostApplicationLifetime.StopApplication(). On Windows, SIGTERM is mapped to a separate handler that requests application stop and then blocks the signal thread for the configured shutdown timeout, so the process is not torn down as soon as the handler returns.

This detail matters in containers. Kubernetes and Docker stop a container by sending SIGTERM to its main process. If your .NET application is that main process, the host receives the signal and begins an orderly shutdown. If it is not, for example because the container’s entrypoint is a shell script that starts dotnet as a child process without forwarding signals, SIGTERM may never reach the application. The orchestrator then waits for its grace period and sends SIGKILL, which cannot be caught. Using the exec form of the Dockerfile ENTRYPOINT, or a shell script that ends with exec dotnet ..., ensures that the .NET process receives the signal directly.

StopApplication() does not stop anything by itself. It triggers the ApplicationStopping cancellation token, which the host’s run loop observes. That causes the host to call IHost.StopAsync, where the real sequence happens.

The shutdown sequence inside the host

The current implementation of Host.StopAsync follows a fixed order. Understanding it explains most shutdown surprises.

html

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>What happens</th>
      <th>Who sees it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1. Timeout armed</td>
      <td>A cancellation token is created that fires after HostOptions.ShutdownTimeout (30 seconds by default)</td>
      <td>Passed to every later step</td>
    </tr>
    <tr>
      <td>2. StoppingAsync</td>
      <td>Called on services implementing IHostedLifecycleService, in reverse registration order</td>
      <td>Lifecycle-aware services (.NET 8 and later)</td>
    </tr>
    <tr>
      <td>3. ApplicationStopping</td>
      <td>IHostApplicationLifetime.ApplicationStopping is triggered, if it has not been already</td>
      <td>Code that registered callbacks on the token</td>
    </tr>
    <tr>
      <td>4. StopAsync</td>
      <td>Called on every IHostedService, in reverse registration order by default</td>
      <td>All hosted services, including the web server and BackgroundService instances</td>
    </tr>
    <tr>
      <td>5. StoppedAsync</td>
      <td>Called on lifecycle services, in reverse order</td>
      <td>Lifecycle-aware services</td>
    </tr>
    <tr>
      <td>6. ApplicationStopped</td>
      <td>IHostApplicationLifetime.ApplicationStopped is triggered</td>
      <td>Final cleanup callbacks</td>
    </tr>
    <tr>
      <td>7. Host lifetime stops</td>
      <td>IHostLifetime.StopAsync runs, and exceptions collected along the way are logged and thrown</td>
      <td>The hosting infrastructure</td>
    </tr>
  </tbody>
</table>

Three properties of this sequence are easy to miss.

The timeout is a single budget, not a per-service allowance. The host creates one cancellation token from ShutdownTimeout at the start of StopAsync and passes it to every phase. If the first service to stop uses 25 of the default 30 seconds, every service after it has five seconds left. Several slow services do not each get 30 seconds.

Services stop in reverse order of registration by default. HostOptions.ServicesStopConcurrently, introduced in .NET 8, defaults to false, so services stop one at a time, last registered first. This ordering is intentional: services registered later are assumed to depend on those registered earlier. Setting the option to true stops them concurrently, which can shorten shutdown but removes that ordering guarantee.

Failures do not abort the sequence. Exceptions from individual services are collected rather than stopping the loop, so other services still get their chance to stop. At the end, the host logs and throws them, as a single exception or as an AggregateException.

The relevant HostOptions defaults in the current source are a ShutdownTimeout of 30 seconds, a StartupTimeout of Timeout.InfiniteTimeSpan, ServicesStartConcurrently and ServicesStopConcurrently both false, and BackgroundServiceExceptionBehavior set to StopHost. The 30-second shutdown default dates from .NET 6; earlier versions used 5 seconds, a difference that still catches teams upgrading old services.

Where the web server fits

In ASP.NET Core, the web server runs as a hosted service. With WebApplication and the Generic Host, hosted services you register start before the server begins accepting requests. Because the host stops services in reverse order, the server is stopped before the hosted services registered ahead of it.

That ordering is usually what you want. When the server’s StopAsync runs, Kestrel stops accepting new connections and gives requests already in progress time to complete, using the same shutdown cancellation token. Requests that are still running when the token fires are aborted. Only after the server has stopped do the earlier-registered background services receive their stop calls, so a worker that, for example, processes messages queued by HTTP requests still runs while the last requests are draining.

The practical consequence is that request draining and background work share the same 30-second budget by default. A slow request that takes most of the shutdown window leaves background services with very little time.

What BackgroundService actually does at shutdown

Most long-running work in .NET services is written as a subclass of BackgroundService. Its implementation is short, and reading it explains how cancellation reaches your code.

In StartAsync, BackgroundService creates a cancellation source linked to the host’s token, starts ExecuteAsync with that source’s token, and returns immediately so the host does not wait for the long-running work. In StopAsync, it cancels the source, which signals the stoppingToken passed to ExecuteAsync, and then waits for the task returned by ExecuteAsync to finish. It stops waiting when the host’s shutdown token fires.

This leads to the central rule for background services: shutdown only works if ExecuteAsync observes stoppingToken. If your loop ignores the token, cancelling it does nothing. StopAsync waits until the shutdown timeout expires, then returns while your code is still running. The host completes its sequence, and the process exits with your work interrupted at an arbitrary point.

A .NET 10 change at startup

.NET 10 changed one aspect of BackgroundService behaviour. In earlier versions, the part of ExecuteAsync that ran before the first await executed synchronously during host startup, which could delay other services. In .NET 10, all of ExecuteAsync runs on a background thread, so it no longer blocks startup. Microsoft documents this as a behavioural breaking change. Code that relied on synchronous setup work completing before other services started should move that work into the constructor, override StartAsync, or implement IHostedLifecycleService. The change does not alter shutdown behaviour, but it is worth knowing when reasoning about service ordering.

A loop that respects shutdown

The following worker reads messages from a source, processes each one and stops cleanly. It assumes an IMessageSource abstraction in your application; the hosting and cancellation APIs are standard.

csharp

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public sealed class OrderWorker(IMessageSource source, ILogger<OrderWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            Message? message;
            try
            {
                message = await source.ReceiveAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break; // Shutdown requested while waiting for work.
            }

            if (message is null)
            {
                continue;
            }

            // Finish the current message even if shutdown starts now,
            // but bound the time so we cannot exceed the host's budget.
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
            await ProcessAsync(message, cts.Token);
        }

        logger.LogInformation("Order worker stopped cleanly.");
    }

    private Task ProcessAsync(Message message, CancellationToken token) =>
        Task.CompletedTask; // Placeholder for real processing.
}

The design makes three decisions explicit.

Waiting for work is cancellable. Passing stoppingToken to ReceiveAsync means the worker leaves the loop promptly when shutdown begins, instead of sitting in a wait until the timeout expires.

In-progress work is allowed to finish, within a bound. Processing uses its own token rather than stoppingToken, so a message that has already been received is completed rather than abandoned halfway. The 10-second limit keeps that work inside the host’s overall shutdown budget. Whether to finish or abandon in-flight work is a design decision. For messages from a queue with acknowledgements, abandoning without acknowledging may be safe, because the broker will redeliver. For work with side effects, finishing may be safer.

The cancellation exception is filtered. The when clause catches OperationCanceledException only when it was caused by shutdown, so cancellations from other sources are not silently swallowed.

Scoped services in workers

Hosted services are registered as singletons. A BackgroundService that needs scoped services, such as an Entity Framework Core DbContext, must create a scope for each unit of work, usually with IServiceScopeFactory.CreateAsyncScope(). At shutdown, the scope’s disposal matters: if a unit of work is cut off mid-transaction, disposing the scope disposes the DbContext, and the open transaction is rolled back by the database rather than committed. Designing each unit of work as a single transaction keeps interrupted work from leaving partial updates.

Exceptions during shutdown

Since .NET 6, BackgroundServiceExceptionBehavior defaults to StopHost: an unhandled exception in ExecuteAsync is logged and stops the whole host. An OperationCanceledException thrown in response to stoppingToken during shutdown is treated as normal completion rather than a failure. Other exceptions that escape during shutdown are collected and reported by the host, which can turn an otherwise clean shutdown into an error in logs and, for some orchestrators, a non-zero exit code.

Why containers break the sequence

So far the host’s clock has been the only one in play. In containers there are at least two more, and they are not coordinated by default.

The orchestrator’s grace period

When Kubernetes terminates a pod, it runs any preStop hook, sends SIGTERM to the containers, and waits for the pod’s terminationGracePeriodSeconds. The default is 30 seconds, and the period includes the time spent in the preStop hook. When it expires, Kubernetes sends SIGKILL. Docker’s docker stop follows the same pattern with a default timeout of 10 seconds before SIGKILL.

The .NET default ShutdownTimeout is also 30 seconds. With a Kubernetes default grace period of 30 seconds, the two clocks are effectively equal, and the orchestrator’s clock started first. Any delay before the .NET process receives SIGTERM, or any work after the host’s timeout, is taken from a budget that ends with an uncatchable kill. Under Docker’s 10-second default, the .NET timeout is three times longer than the container actually has.

html

<table>
  <thead>
    <tr>
      <th>Clock</th>
      <th>Default</th>
      <th>What happens when it expires</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Kubernetes terminationGracePeriodSeconds (includes preStop)</td>
      <td>30 seconds</td>
      <td>SIGKILL; no further code runs</td>
    </tr>
    <tr>
      <td>docker stop timeout</td>
      <td>10 seconds</td>
      <td>SIGKILL; no further code runs</td>
    </tr>
    <tr>
      <td>.NET HostOptions.ShutdownTimeout</td>
      <td>30 seconds (since .NET 6)</td>
      <td>Host stops waiting for remaining services; in-progress requests and work are abandoned</td>
    </tr>
    <tr>
      <td>Your own work timeouts</td>
      <td>Whatever you set</td>
      <td>Depends on your code</td>
    </tr>
  </tbody>
</table>

The rule that follows is simple: the host’s shutdown timeout must be shorter than the orchestrator’s grace period, with enough margin for the time before SIGTERM arrives and for any work after the host stops. If Kubernetes allows 30 seconds and you use a 10-second preStop delay, the host should finish within roughly 15 seconds, not 30.

csharp

builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(15);
});

Alternatively, raise terminationGracePeriodSeconds in the pod specification so that the orchestrator’s budget comfortably exceeds the host’s. Either way, both values must be chosen together.

Requests arriving after SIGTERM

The second container problem is more subtle. When a pod begins terminating, Kubernetes removes it from service endpoints, but that removal propagates to load balancers and proxies asynchronously. For a short time after SIGTERM, new requests can still be routed to a pod that has already started shutting down. Because Kestrel stops accepting new connections as soon as the server’s StopAsync runs, those late requests may fail with connection errors.

The common mitigation is a short delay before shutdown begins, so routing updates propagate while the application is still serving traffic. In Kubernetes this is usually done with a preStop hook that sleeps for a few seconds; recent Kubernetes versions support a built-in sleep action for this purpose, while older clusters use a hook that runs a sleep command inside the container. The delay comes out of the grace period, which is another reason to set the host’s timeout with the full budget in mind.

Readiness probes help in the same direction. If the application starts reporting itself as not ready when ApplicationStopping fires, traffic is withdrawn more quickly. ASP.NET Core health checks can be wired to the lifetime tokens so that a readiness endpoint returns unhealthy once shutdown has begun.

Diagnosing shutdown problems

Shutdown failures usually look like intermittent errors during deployments rather than clear crashes. A few patterns point to specific causes.

html

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Likely cause</th>
      <th>Where to look</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Shutdown always takes exactly the timeout length</td>
      <td>A BackgroundService ignores stoppingToken</td>
      <td>Loops, Task.Delay or waits called without the token</td>
    </tr>
    <tr>
      <td>Container exits with code 137 during deployments</td>
      <td>Process killed by SIGKILL after the grace period</td>
      <td>Host timeout longer than the grace period, or SIGTERM not reaching the process</td>
    </tr>
    <tr>
      <td>Shutdown logs never appear</td>
      <td>SIGTERM not delivered to the .NET process</td>
      <td>Dockerfile entrypoint form, wrapper scripts without exec</td>
    </tr>
    <tr>
      <td>Client errors only during rollouts</td>
      <td>Requests routed to pods already stopping</td>
      <td>preStop delay, readiness probe behaviour</td>
    </tr>
    <tr>
      <td>Later services time out while earlier ones finish</td>
      <td>One slow service consumes the shared shutdown budget</td>
      <td>Order of registration, per-service work timeouts, ServicesStopConcurrently</td>
    </tr>
    <tr>
      <td>Duplicate processing after restarts</td>
      <td>Work interrupted after side effects but before acknowledgement</td>
      <td>Idempotency of handlers, acknowledgement timing</td>
    </tr>
  </tbody>
</table>

The host’s own logs are the first diagnostic tool. With the Microsoft.Hosting.Lifetime category at Information level, the host logs when the application is shutting down. Adding your own log entries at the start and end of each service’s stop logic shows how the shutdown budget is actually spent. In Kubernetes, the pod’s events and the container’s last exit code show whether the process exited on its own or was killed.

Making shutdown part of the design

Graceful shutdown is easier to design in than to retrofit. A few principles cover most services.

Treat every unit of work as interruptible. Even with perfect shutdown handling, processes die from out-of-memory kills, node failures and crashes. Handlers that are idempotent, transactions that commit atomically and message acknowledgements sent only after work completes make interruptions safe regardless of their cause.

Pass cancellation tokens everywhere. Every awaited I/O call in a background service should receive a token, either stoppingToken or one derived from it. A single uncancellable wait can consume the entire shutdown budget.

Budget shutdown explicitly. Decide how long request draining, queue draining and flushing should take, and set ShutdownTimeout and terminationGracePeriodSeconds from those numbers rather than relying on defaults.

Drain in-memory buffers deliberately. Producer-consumer pipelines built on System.Threading.Channels hold work in memory. At shutdown, decide whether to complete the writer and drain the remaining items within the budget, or to stop immediately and rely on upstream redelivery. Leaving the choice implicit usually means losing whatever was buffered.

Test the real path. Running the application locally and pressing Ctrl+C sends SIGINT, which follows the same host path as SIGTERM on Linux. Testing inside a container with docker stop, and in a staging cluster with a real rollout, shows whether the signal, the timeouts and the routing behave as intended together.

The sequence in one paragraph

When a .NET service is asked to stop, the signal handler requests application stop, the host arms a single shutdown timer, and services stop one by one in reverse order of registration, with the web server draining requests first and background services stopping after it. BackgroundService can stop cleanly only if its code observes the stopping token. In containers, all of this must fit inside an orchestrator grace period that started earlier and ends with an uncatchable kill. Services that get the order, the tokens and the timeouts right shut down as reliably as they start. Those that leave them to defaults usually discover the problem during a deployment, as a handful of failed requests and a few lost messages that are hard to reproduce.

More from Dot Net Masters