IIS will recycle your app pool whether you planned for it or not. If the ASP.NET Core process ignores that signal, in-flight requests get cut off, background work leaves half-written rows, and SQL connection pools are torn down mid-command.
The fix is not “disable recycles.” It is aligning three clocks: the IIS shutdownTimeLimit, the .NET HostOptions.ShutdownTimeout, and your own ApplicationStopping handlers so the worker can drain cleanly under the in-process (ANCM) model that most Windows hosts use.
#Why the worker process disappears
On Windows Server with IIS 10.0, the app pool—not Kestrel—owns the process lifetime when you run hostingModel="inprocess". Recycles are normal operations, not failures. Typical triggers on shared and VPS boxes:
- Regular time interval (default 1740 minutes) or a fixed daily schedule
- Idle timeout after no requests (often 20 minutes on shared pools)
- web.config or appsettings change that touches the site
- Memory or private-bytes limits on the pool
- Manual recycle after a Web Deploy publish
Overlapped recycle starts a new worker before the old one exits. That only helps if the old worker actually finishes outstanding work inside the shutdown window. Otherwise users still see dropped POSTs and broken SignalR connections during the handoff.
#Hook IHostApplicationLifetime early
In a .NET 10 minimal host, resolve IHostApplicationLifetime once at startup and register stop callbacks before app.Run(). Keep the callbacks short: set a CancellationToken, complete a Channel, or flip a gate your middleware already checks. Do not run multi-second cleanup directly on the callback thread.
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<HostOptions>(o =>
{
// Must stay under IIS shutdownTimeLimit (seconds)
o.ShutdownTimeout = TimeSpan.FromSeconds(25);
});
builder.Services.AddSingleton<DrainGate>();
builder.Services.AddHostedService<QueueWorker>();
var app = builder.Build();
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
var drain = app.Services.GetRequiredService<DrainGate>();
lifetime.ApplicationStopping.Register(() =>
{
drain.Cancel(); // cooperative cancel for workers + middleware
});
app.Use(async (ctx, next) =>
{
if (drain.IsCancelling)
{
ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
ctx.Response.Headers.Connection = "close";
await ctx.Response.WriteAsync("Shutting down");
return;
}
await next();
});
app.MapGet("/health/live", () => Results.Ok("ok"));
app.MapGet("/health/ready", (DrainGate g) =>
g.IsCancelling ? Results.StatusCode(503) : Results.Ok("ready"));
app.Run();
Returning 503 with Connection: close during drain keeps load balancers and IIS from feeding the dying worker new work while overlapped recycle brings the replacement up. Keep /health/live cheap and still green if the process is alive; flip only readiness so orchestrations and reverse proxies stop routing early.
#Match IIS shutdownTimeLimit to HostOptions
ANCM sends the stop signal, then waits shutdownTimeLimit seconds before hard-killing the worker. If your HostOptions.ShutdownTimeout is longer than that value, .NET never finishes its own teardown. Set the IIS limit slightly above the host timeout so graceful code wins.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*"
modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet"
arguments=".\MyApp.dll"
stdoutLogEnabled="false"
hostingModel="inprocess"
shutdownTimeLimit="30"
rapidFailsPerMinute="10" />
</system.webServer>
</configuration>
After Web Deploy, confirm the published web.config still carries shutdownTimeLimit. Some publish profiles rewrite the aspNetCore node and drop custom attributes. Keep the attribute in a web.config transform or a dedicated web.Release.config so it survives every publish.
#Drain SQL and background work cooperatively
SqlClient and EF Core honor a CancellationToken on most async APIs. Pass the drain token into your unit-of-work path so a recycle does not leave open transactions. For IHostedService workers, stop reading the queue as soon as ApplicationStopping fires, finish the current message, then exit.
public sealed class QueueWorker : BackgroundService
{
private readonly DrainGate _drain;
private readonly IServiceScopeFactory _scopes;
public QueueWorker(DrainGate drain, IServiceScopeFactory scopes)
{
_drain = drain;
_scopes = scopes;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
stoppingToken, _drain.Token);
while (!linked.IsCancellationRequested)
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var job = await db.Jobs
.Where(j => j.Status == JobStatus.Queued)
.OrderBy(j => j.Id)
.FirstOrDefaultAsync(linked.Token);
if (job is null)
{
await Task.Delay(500, linked.Token);
continue;
}
job.Status = JobStatus.Running;
await db.SaveChangesAsync(linked.Token);
// process job...
}
}
}
On the IIS side, leave overlapped recycle enabled (the default) and avoid stacking a very short idle timeout with a heavy startup path. If the site is quiet for long stretches on a shared pool, prefer a readiness probe or a warm-up request after recycle over disabling idle timeout entirely—idle pools still protect memory on multi-tenant servers.
Practical takeaway: treat every IIS recycle as a planned deploy. Set shutdownTimeLimit to 30s, set HostOptions.ShutdownTimeout a few seconds lower, cancel a shared DrainGate from ApplicationStopping, serve 503 on readiness while draining, and pass that token into EF Core and queue workers. Do that once in the host setup and app pool recycles stop looking like random outages on Windows/.NET hosting.
Comments
No comments yet