The slowest page on many IIS-hosted ASP.NET Core sites is not a query or a view—it is the first request after an app pool recycle. The worker process starts, the ASP.NET Core Module boots the app, DI builds the graph, EF Core warms the model, and only then does your code run. Everyone else waits on that first visitor.
IIS Application Initialization exists to take that hit off the public path. You point IIS at one or more warm-up URLs; when the pool starts (or when preload is enabled), IIS issues those requests so the app is already running before customers arrive. On a Windows VPS or dedicated box you control the pool. On shared hosting you control less—but you can still design a cheap warm-up endpoint and use whatever knobs the host exposes.
#What actually causes the cold start
For ASP.NET Core in-process on IIS, startup cost is dominated by process launch, runtime load, configuration binding, service registration, and first-use work in middleware and data access. Out-of-process adds the reverse-proxy hop to Kestrel, which is usually smaller than full app startup but still visible after a recycle.
Recycles are normal ops, not failures: overlapping recycle, scheduled recycle, config change, deployment, idle timeout, or a memory limit. If your pool goes idle overnight on a low-traffic site, the morning’s first shopper or monitor check owns the cold start. Health checks help you detect a dead process; they do not by themselves preload a stopped pool unless something is already pinging on a schedule and the pool is allowed to start.
#Pool and site settings that enable preload
Full preload needs three pieces working together: the Application Initialization feature on the server, an app pool that does not stay on-demand forever, and a site with preload enabled. On Server 2025 with IIS 10.0 the feature is optional—confirm it is installed before chasing config.
# Run elevated on a box you administer
Get-WindowsFeature *Application-Init*
# If absent:
# Install-WindowsFeature Web-AppInit
Import-Module WebAdministration
Set-ItemProperty IIS:\AppPools\MySiteAppPool -Name startMode -Value AlwaysRunning
Set-ItemProperty IIS:\Sites\MySite -Name applicationDefaults.preloadEnabled -Value $true
AlwaysRunning keeps the worker process up after IIS starts. preloadEnabled tells IIS to issue initialization requests when the worker starts instead of waiting for the first external client. Without both, web.config warm-up entries alone only help in narrower cases and are easy to misread as “set and forget” on shared plans where pool startMode is locked.
#Warm-up entries in web.config
Put initialization paths under system.webServer. Use a lightweight route that still exercises the same host builder as production traffic—skip the homepage if it hammers SQL or remote APIs. A dedicated /warmup or /health/ready path is easier to keep fast and safe.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<applicationInitialization
doAppInitAfterRestart="true"
skipManagedModules="false">
<add initializationPage="/warmup" hostName="www.example.com" />
</applicationInitialization>
</system.webServer>
</configuration>
doAppInitAfterRestart matters after recycles and config changes. hostName should match a binding you actually serve, especially on SNI-heavy hosts where the wrong host header yields a different site or certificate path. initializationPage is a site-relative path; query strings are allowed if your warm-up needs a flag.
For ASP.NET Core, the request must reach your app through the ASP.NET Core Module. A static file that never hits managed code will not JIT your controllers or build the DI container. If you use path-base or a virtual application, the initializationPage must include that prefix.
#Write a warm-up endpoint that stays cheap
The warm-up action should prove the app can run without becoming a denial-of-service vector. Do not run full migrations, rebuild caches for every tenant, or call paid third parties on every recycle. Touch configuration, the DI root, and optionally one pooled SQL connection with a fast query.
// ASP.NET Core 10 — map a minimal warm-up that hosts can hit safely
app.MapGet("/warmup", async (IConfiguration config, SqlConnectionFactory db) =>
{
_ = config["ConnectionStrings:App"]; // force config bind
await db.OpenAndPingAsync(); // SELECT 1, short command timeout
return Results.Text("ok", "text/plain");
}).AllowAnonymous();
// Keep it out of public sitemaps; optionally require a shared header
// only IIS init and your monitors know about.
- Return 200 quickly; avoid 301/302 chains that complicate init.
- Do not require auth for the IIS init request unless you also configure init with headers the module will send—anonymous plus network restriction is simpler.
- Cap SQL work at a connection open + trivial read; leave cache priming to a background service with its own limits.
- Log warm-up distinctly so you can tell recycles from real traffic in stdout or your log provider.
#Shared hosting vs servers you administer
On a Windows Server you manage, AlwaysRunning plus preloadEnabled plus applicationInitialization is the full pattern. Pair it with a sensible recycle schedule so warm-up runs when you expect, not only when memory trips at peak.
On Windows shared hosting, app pool startMode and site preload are often reserved. You may still deploy the web.config section if Application Initialization is installed farm-wide, but behavior depends on the host. Practical fallbacks: keep startup lean (lazy heavy services), prefer in-process hosting for fewer moving parts, avoid unnecessary idle shutdowns if the panel exposes idle timeout, and use an external monitor against /warmup on an interval only if that matches the host’s fair-use rules.
Also watch overlapping recycle and deployment: a warm-up that runs while the new app is half-copied will fail noisily. Coordinate with app_offline or your slot/folder swap so initialization runs against a complete publish output.
#Verify before you trust it
After enabling preload, recycle the pool and watch both IIS logs and your app logs. You want an internal-style request to /warmup at worker start, then normal traffic with steady latency. Failed Request Tracing or the ASP.NET Core Module event log entries help when init returns 500 because hostName or path-base is wrong.
If warm-up never fires, check feature install, preloadEnabled, startMode, and whether a reverse proxy in front is required for host headers. If it fires but the first real user is still slow, your /warmup path is not exercising the expensive code—move the costly one-time work behind that route or into IHostedService with care about multi-instance duplication.
Practical takeaway: treat cold start as an ops surface, not an app mystery. On boxes you own, turn on Application Initialization, AlwaysRunning, and preload, and aim IIS at a cheap /warmup that opens config and SQL the same way production does. On shared Windows hosts, ship that endpoint anyway, keep startup thin, and use only the pool controls your panel allows—so the first human request after recycle is not unpaid build time. That is the difference between a recycle you scheduled and a support ticket at 9:05 a.m.
Comments
No comments yet