ASP.NET Core is the rare framework that takes health checks seriously out of the box: a first-party middleware, a rich community probe ecosystem, and hosting on Kestrel that routinely tops TechEmpower benchmarks. But .NET apps fail in characteristically .NET ways — thread pool starvation from a single stray .Result, a BackgroundService that dies without a sound (or, since .NET 6, takes the whole host with it), and IIS app pools that recycle or idle out underneath an otherwise healthy process.
This guide walks through monitoring an ASP.NET Core application the way it actually breaks. We will wire up the built-in health checks middleware properly, enumerate the .NET-specific failure modes worth alerting on, cover hosted services and scheduled work, and finish with a concrete CronAlert setup you can copy. CronAlert is agentless and runs on Cloudflare's edge, so everything here is plain HTTP checks, content assertions, and heartbeats — no APM agent to install.
Use the built-in health checks middleware — properly
Unlike Go or Express, where you hand-roll the endpoint, ASP.NET Core ships one. The minimal version is two lines, but the version worth monitoring checks real dependencies with a bounded timeout:
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, timeout: TimeSpan.FromSeconds(2))
.AddRedis(redisConnection, timeout: TimeSpan.FromSeconds(2));
// Liveness: is the process up? No dependencies.
app.MapHealthChecks("/alive", new HealthCheckOptions {
Predicate = _ => false
});
// Readiness: can we actually serve? Dependencies included.
app.MapHealthChecks("/healthz"); The AddNpgSql, AddSqlServer, AddRedis, and AddDbContextCheck probes come from the community AspNetCore.Diagnostics.HealthChecks packages — use them rather than reimplementing. Three decisions matter, and they mirror our health check endpoints guide: bound every probe with a timeout so the endpoint can't hang, keep the liveness/readiness split if you run on Kubernetes so a database blip doesn't become a restart loop, and don't leak dependency details in the response. The default body is literally the string Healthy — a perfect target for a keyword assertion, since a load balancer's generic 200 error page will never contain it. Point your external monitor at the dependency-aware /healthz; that's the one that tells the truth. The database health endpoint guide covers what a good database probe verifies.
Thread pool starvation: the .NET-specific slow death
The most famous ASP.NET Core production failure isn't a crash — it's starvation. One library call written as task.Result or .Wait() instead of await blocks a thread pool thread; under load, dozens of requests block simultaneously, the pool runs dry, and every request — including your health check — queues behind threads that are waiting on each other. CPU sits near idle while requests take 30 seconds or time out. The app looks "up" to a TCP check and is useless to users.
Because the thread pool injects new threads slowly (roughly one per 500ms when starved), the failure has a visible ramp: latency climbs over minutes. That makes a response-time threshold on your monitors the early-warning system — it fires during the ramp, before the hard timeouts. Our timeout thresholds guide covers picking the numbers. The fix is async all the way down; the monitoring is what tells you a regression shipped.
What to monitor in an ASP.NET Core app
- The homepage or API root. Whatever real clients hit first, checked for a 200 and expected content — not just that Kestrel answered.
- Your
/healthz. The dependency-aware readiness endpoint, with a keyword assertion onHealthy. - Your most important endpoints. The two or three routes the product depends on, each asserting on a JSON field only present in a correct response — see monitoring API endpoints.
- HTTPS and the SSL certificate. Kestrel behind a reverse proxy, Azure-managed certs, or IIS bindings — wherever TLS terminates, an expired certificate takes down every client; see SSL certificate monitoring.
- SignalR negotiate endpoints, if you use them.
/hub/negotiatefailing breaks every real-time feature while the rest of the site looks fine; our WebSocket monitoring guide covers the pattern.
The HTTP and SSL checks here fit comfortably inside CronAlert's free plan — 25 monitors at a 3-minute interval. Pro ($5/mo) tightens the interval to 1 minute and unlocks the keyword assertions and heartbeats used below.
.NET-specific failure modes
- Thread pool starvation. Covered above — the ramp is your signal; alert on response time, not just status.
- BackgroundService death. Pre-.NET 6: an unhandled exception in
ExecuteAsyncwas silently swallowed — HTTP green, background work stopped. .NET 6+: the defaultBackgroundServiceExceptionBehavior.StopHoststops the whole application instead. One default hides the failure, the other amplifies it; heartbeats (next section) catch both. - IIS app pool recycling and idle timeout. On IIS or Azure App Service, the pool idles out after 20 minutes without traffic and recycles every 29 hours by default. Users pay cold starts; in-memory state and background timers die. Set Always On (Azure) or
AlwaysRunning(IIS) — and note that an external monitor checking every 1–3 minutes keeps the pool warm as a side effect. - EF Core / connection pool exhaustion. Long transactions, missed disposal, or an undersized
Max Pool Size(100 by default for SQL Server) leave every query waiting until timeout. The process is alive; the app is stalled. A health probe that actually queries the database surfaces the stall early. - Memory growth and Gen2/LOH pressure. Caching without bounds, closures capturing large graphs, or
IMemoryCachemisuse ramp memory until the container OOMs. Externally it looks like periodic restarts — flapping monitors are the tell. - Deploy-time half-starts. A container that starts but can't reach its database sits in a retry loop with the port open. A dependency-aware
/healthzrefuses to reportHealthy; a bare TCP check happily passes.
The recurring lesson from every framework in this series: status codes lie. Dependency-aware checks, content assertions, and response-time thresholds see the truth — and in .NET's case the response-time threshold does double duty, because starvation and pool exhaustion both degrade long before they kill.
Monitoring IHostedService and scheduled work
.NET apps idiomatically run scheduled work inside the host: a BackgroundService with a PeriodicTimer loop, Quartz.NET schedules, or Hangfire recurring jobs. All of it is invisible to HTTP checks — when a worker dies, no request fails; invoices just stop going out.
The fix is heartbeat monitoring: the worker reaches out instead of the monitor reaching in. Each cycle that completes successfully pings a unique CronAlert heartbeat URL; if the ping stops arriving within the expected interval plus grace, CronAlert alerts you. Silence is the alarm.
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(ct))
{
try
{
await SyncInvoicesAsync(ct);
// Ping CronAlert only after verified success
await _httpClient.GetAsync(
"https://cronalert.com/api/heartbeat/TOKEN", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "invoice sync failed");
// no ping — silence triggers the alert
}
}
} One heartbeat per critical job — the nightly export, the hourly sync, the Hangfire recurring job — and every failure mode above (swallowed exception, stopped host, recycled app pool killing timers) collapses into the same observable symptom: the ping stops. Our background worker monitoring guide covers the patterns in depth. Heartbeat monitors are available on every paid CronAlert plan, from $5/mo.
A concrete CronAlert setup
Here is an end-to-end setup you can replicate in a few minutes after you create a free CronAlert account:
- Map health checks with real probes.
AddHealthChecks()plus the community packages for your database and cache, with 2-second timeouts. - Create an HTTP monitor for
/healthz. Expect 200, set the interval (3 minutes free, 1 minute on Pro), and add a keyword assertion onHealthy(Pro). - Monitor key endpoints with JSON keyword assertions (Pro). Require a field name only present in a correct response.
- Add a heartbeat per hosted service (Pro).
PeriodicTimerloops, Quartz schedules, and Hangfire recurring jobs ping only after verified success. - Turn on SSL and response-time checks. Certificate monitoring on the domain; response-time thresholds on
/healthzand key routes — your starvation and pool-exhaustion early warning. - Fix the hosting config while you're at it. Always On / disable idle timeout, and review the recycle schedule — the monitoring makes the cold starts visible; the config change removes them.
- Wire up alert channels. Email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Splunk On-Call, webhooks, or PWA push.
Every plan includes the REST API (read-only on free, full read-write from Pro), so you can manage monitors from CI — and CronAlert ships an MCP server so Claude Code, Cursor, or Windsurf can manage monitors for you. Teams needing multi-region coverage can move to the Team plan, which checks from five edge regions with quorum so a single regional blip never pages you.
Frequently asked questions
Does ASP.NET Core have a built-in health check endpoint?
Yes — AddHealthChecks() plus MapHealthChecks("/healthz"), with community packages providing probes for SQL Server, PostgreSQL, Redis, RabbitMQ, and more. Assert on the 200 status and the Healthy body text from your external monitor.
Why is my ASP.NET Core app slow or unresponsive under load?
Most often thread pool starvation from sync-over-async (.Result/.Wait()). It builds gradually as the pool injects threads slowly, so a response-time threshold on your monitors catches the ramp before the timeouts start.
Why did my BackgroundService stop running without any error?
Pre-.NET 6, unhandled ExecuteAsync exceptions were silently swallowed; since .NET 6 they stop the whole host by default. Heartbeat monitoring catches both: the worker pings after each successful cycle, and silence triggers the alert.
Why does the first request to my ASP.NET Core site take 30 seconds?
IIS/Azure App Service idle timeout (20 minutes) or the default 29-hour recycle. Enable Always On or AlwaysRunning, and monitor at an interval shorter than the idle timeout — which also keeps the pool warm.
How do I monitor EF Core connection pool exhaustion?
Use a health probe that actually queries the database (AddDbContextCheck or a provider probe) and put a response-time threshold on /healthz — exhaustion shows up as a stall long before hard timeouts.
Start monitoring your ASP.NET Core app
.NET apps rarely die loudly: they starve, stall, recycle, and quietly stop their background work while Kestrel keeps answering. The built-in health checks middleware plus keyword assertions on key routes, heartbeats on hosted services, and SSL and response-time checks give you a signal you can trust — the HTTP and SSL checks start on CronAlert's free plan, and keyword assertions plus heartbeats come with Pro at $5/mo. Create a free CronAlert account and point your first check at /healthz in the next five minutes.
Related reading: HTTP health check endpoints, building a database health endpoint, background worker monitoring, and our companion guides for Django, Rails, Laravel, FastAPI, Express/Node.js, Spring Boot, and Go applications.