HostOptions:ShutdownTimeout

Set how long the host waits for in-flight requests and hosted services on shutdown, and line it up with Kubernetes terminationGracePeriodSeconds.

Signature
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25));  |  shutdownTimeoutSeconds=25
  • Added in .NET ASP.NET Core 2.1

Parameters

ParameterTypeDescription
$ShutdownTimeoutoptionalTimeSpan= 00:00:30Graceful stop budget. Bound automatically only from the host key shutdownTimeoutSeconds (DOTNET_SHUTDOWNTIMEOUTSECONDS); a HostOptions configuration section must be bound by you.
$StartupTimeoutoptionalTimeSpan= Timeout.InfiniteTimeSpanBudget for StartAsync of all hosted services; Infinite by default.
$ServicesStartConcurrentlyoptionalbool= falseStart hosted services in parallel instead of in registration order.
$ServicesStopConcurrentlyoptionalbool= falseStop hosted services in parallel instead of in reverse registration order.
$BackgroundServiceExceptionBehavioroptionalBackgroundServiceExceptionBehavior= StopHostStopHost or Ignore when a BackgroundService's ExecuteAsync throws.

Return value

TimeSpanThe time the host gives IHostedService.StopAsync calls (including the web server draining requests) before it cancels the token passed to them.

Errors

Nothing when the timeout elapses: the token passed to StopAsync is cancelled. A StopAsync that ignores the token keeps the host waiting past the timeout, until the orchestrator kills the process.

Changelog

  • PHP .NET 6Default ShutdownTimeout raised from 5 seconds to 30 seconds.
  • PHP .NET 8StartupTimeout, ServicesStartConcurrently and ServicesStopConcurrently added.

When the host stops (SIGTERM from Kubernetes or Docker, Ctrl+C, or IHostApplicationLifetime.StopApplication()), it fires ApplicationStopping, then calls StopAsync on every hosted service in reverse registration order with a cancellation token that is cancelled after ShutdownTimeout. Kestrel is one of those services: it stops accepting connections and waits for in-flight requests until that token fires, then aborts what is left. A BackgroundService has its stoppingToken cancelled when the host calls that service's StopAsync. With the default sequential stopping, that happens only after services registered later have stopped, and WebApplication registers Kestrel last, so the request drain runs first. All of them share one ShutdownTimeout budget, so a background service may get only what the drain left over. ServicesStopConcurrently = true starts every stop at once. The default timeout is 30 seconds.

The configuration key trap

The title key is the trap. "HostOptions": { "ShutdownTimeout": "00:00:25" } in appsettings.json does nothing on its own: the host does not bind a HostOptions section. Three ways that do work:

HowFormatNotes
Configure<HostOptions>(o => o.ShutdownTimeout = ...)TimeSpan in codeClearest; reviewable
Configure<HostOptions>(config.GetSection("HostOptions"))"00:00:25" under HostOptionsMakes the section in the title real
Host key shutdownTimeoutSecondsWhole seconds, root of config; env DOTNET_SHUTDOWNTIMEOUTSECONDSBuilt in, no code

Lining up with Kubernetes

On pod deletion Kubernetes runs the preStop hook, sends SIGTERM, and sends SIGKILL when terminationGracePeriodSeconds (default 30) runs out, counted from the start. The endpoint is removed from Services at the same time as SIGTERM, but load balancers notice a few seconds later, so new requests can still arrive. The usual layout: preStop sleep of 5-10 seconds so routing catches up, then a ShutdownTimeout shorter than what is left. For example grace 45 s = preStop 10 s + ShutdownTimeout 30 s + 5 s spare. With the defaults (grace 30, timeout 30) any preStop delay means SIGKILL arrives while requests are still draining.

What the timeout cannot do

The timeout only cancels a token. A StopAsync that ignores it, or a BackgroundService blocked in a synchronous call, keeps the host waiting; the process then dies by SIGKILL with no final logs and no flushed telemetry. Pass the token into every awaited call on the stop path, and keep long work (flushing an outbox batch, committing a consumer offset) small enough to finish inside the budget.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Give an orders API 30 seconds to drain in-flight checkouts while Kubernetes waits 45 seconds before SIGKILL
  • Let a RabbitMQ consumer finish and ack its current message on SIGTERM instead of redelivering it after a hard kill
  • Make the HostOptions section in appsettings.json actually apply by binding it with Configure<HostOptions>(section)

Frequently asked questions

What is the default ShutdownTimeout?

30 seconds since .NET 6 (it was 5 seconds before). It matches Kubernetes' default terminationGracePeriodSeconds of 30, which leaves no room for a preStop delay unless you change one of them.

Why is HostOptions:ShutdownTimeout in appsettings.json ignored?

The host does not bind a HostOptions section. It reads only the root key shutdownTimeoutSeconds. Bind the section with builder.Services.Configure<HostOptions>(builder.Configuration.GetSection("HostOptions")) or set the option in code.

What happens when the timeout is reached?

The token given to StopAsync is cancelled and Kestrel aborts remaining connections. Services that ignore the token keep running until the process exits or is killed.

More examples

Kubernetes timings that fit together

10 seconds of preStop lets the ingress and kube-proxy stop routing to the pod, then the app has 30 seconds to drain, and 5 seconds remain before SIGKILL. The <code>sleep</code> action needs no shell, so it works with chiseled images; older clusters use an exec hook running sleep instead.

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          image: registry.example.com/orders-api:1.8.2
          env:
            - name: DOTNET_SHUTDOWNTIMEOUTSECONDS
              value: "30"
          lifecycle:
            preStop:
              sleep:
                seconds: 10

Setting the timeout in code with the other host options

<code>ServicesStopConcurrently</code> stops hosted services in parallel, so one slow consumer does not eat the budget of the others. The <code>ApplicationStopping</code> log line marks the start of the drain, which makes a later SIGKILL easy to spot in the logs.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
    options.StartupTimeout = TimeSpan.FromSeconds(60);
    options.ServicesStopConcurrently = true;
    options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
});

var app = builder.Build();

app.Lifetime.ApplicationStopping.Register(() =>
    app.Logger.LogInformation("SIGTERM received, draining in-flight requests"));

app.MapGet("/orders/{id:int}", async (int id, CancellationToken ct) =>
{
    await Task.Delay(200, ct);
    return TypedResults.Ok(new { id, status = "packed" });
});

app.Run();

A BackgroundService that respects the budget

<code>stoppingToken</code> is cancelled when the host reaches this service's <code>StopAsync</code>; with sequential stopping that comes after Kestrel has drained requests, out of the same <code>ShutdownTimeout</code> budget. The host then waits for <code>ExecuteAsync</code> to return until that budget runs out. Passing the token into every await and keeping batches small means the loop exits in milliseconds instead of being cut off.

Example · csharp
public sealed class InvoiceEmailSender(ILogger<InvoiceEmailSender> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await SendNextBatchAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
        }
        logger.LogInformation("Invoice email sender stopped cleanly");
    }

    private static async Task SendNextBatchAsync(CancellationToken ct)
    {
        // Small batches: each one finishes well inside the shutdown budget.
        for (var i = 0; i < 20; i++)
        {
            await Task.Delay(50, ct);
        }
    }
}

Discussion

  • Be the first to comment on this lesson.