Learn ASP.NET Core health checks step by step — monitor databases, APIs, and memory in production with real C# examples. Start monitoring like a pro today.
When your production app goes down at 2 AM, how quickly will you know? If your answer is "when a customer emails us," you need ASP.NET Core health checks. Health checks are lightweight endpoints built into ASP.NET Core that report whether your application — and everything it depends on, like SQL Server, Redis, or third-party APIs — is actually working. Load balancers, Kubernetes, and monitoring dashboards poll these endpoints continuously, so failures are detected and handled in seconds, not hours.
In this guide, you'll learn how to add a health check endpoint in ASP.NET Core, monitor databases and external services, distinguish liveness from readiness probes, secure your endpoints, and visualize everything with a dashboard. Every example is runnable on .NET 8 and .NET 9.
What Are Health Checks in ASP.NET Core?
A health check is a small piece of code that answers one question: is this part of my system healthy right now? ASP.NET Core ships with a first-class health checks framework in the Microsoft.Extensions.Diagnostics.HealthChecks package (included in the shared framework, so no extra install is needed for the basics).
Each check returns one of three states:
- Healthy — the component is working normally.
- Degraded — it works, but slowly or partially (e.g., a query took 5 seconds instead of 50 ms). The app can still serve traffic.
- Unhealthy — the component is broken. Orchestrators may restart the app or pull it out of the load balancer rotation.
Why does this matter? Because modern apps rarely fail all at once. Your web server might be fine while your database connection pool is exhausted. Without health checks, that failure surfaces as mysterious 500 errors sprinkled across user requests. With health checks, your infrastructure sees "database: unhealthy" and can react automatically — restart the pod, fail over, alert the on-call engineer — before most users notice.
Adding Your First Health Check Endpoint
The simplest possible setup takes two lines in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
Run the app and browse to https://localhost:5001/health. You'll see a plain-text response: Healthy, with HTTP status 200. If any registered check fails, the endpoint returns 503 Service Unavailable — exactly what load balancers look for.
This "empty" check is already useful: it proves the ASP.NET Core pipeline is up and processing requests. But the real power comes from checking your dependencies.
Checking Databases, Redis, and External APIs
The community-maintained AspNetCore.Diagnostics.HealthChecks project provides ready-made checks for virtually every dependency you'll ever use. Install the ones you need:
// Package references:
// dotnet add package AspNetCore.HealthChecks.SqlServer
// dotnet add package AspNetCore.HealthChecks.Redis
// dotnet add package AspNetCore.HealthChecks.Uris
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: builder.Configuration.GetConnectionString("Default")!,
name: "sql-server",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "ready", "database" })
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis-cache",
failureStatus: HealthStatus.Degraded, // cache down = slower, not broken
tags: new[] { "ready", "cache" })
.AddUrlGroup(
new Uri("https://api.payment-provider.com/status"),
name: "payment-api",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "ready", "external" });
Notice two deliberate decisions here, because this is where the why matters:
- Redis failing is marked Degraded, not Unhealthy. If your app can fall back to the database when the cache is down, it shouldn't be killed by Kubernetes just because Redis hiccupped. Map failure status to actual business impact.
- Tags group checks by purpose. We'll use the
"ready"tag in a moment to build separate liveness and readiness endpoints — one of the most important patterns in ASP.NET Core production monitoring.
Writing a Custom Health Check in C#
When no prebuilt check fits — say you need to verify disk space or a message queue backlog — implement IHealthCheck:
public class MemoryHealthCheck : IHealthCheck
{
private const long ThresholdBytes = 1_024L * 1_024L * 1_024L; // 1 GB
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var allocated = GC.GetTotalMemory(forceFullCollection: false);
var data = new Dictionary<string, object>
{
["AllocatedBytes"] = allocated,
["Gen0Collections"] = GC.CollectionCount(0),
["Gen2Collections"] = GC.CollectionCount(2)
};
var status = allocated < ThresholdBytes
? HealthCheckResult.Healthy("Memory usage is normal.", data)
: HealthCheckResult.Degraded(
$"High memory usage: {allocated / 1_048_576} MB allocated.", data: data);
return Task.FromResult(status);
}
}
Register it with a timeout so a hung check can't stall the whole endpoint:
builder.Services.AddHealthChecks()
.AddCheck<MemoryHealthCheck>(
"memory",
tags: new[] { "live" },
timeout: TimeSpan.FromSeconds(3));
The data dictionary rides along in the JSON response (shown next), giving your monitoring system real numbers to graph and alert on — not just a binary up/down.
Liveness vs. Readiness: The Pattern That Saves Production
This is the single most important concept in this article. Kubernetes, Azure App Service, and AWS load balancers ask two different questions:
- Liveness — "Is the process alive, or should I restart it?" This should only check the app itself. If your liveness probe includes the database and the database goes down, Kubernetes will restart every pod in a loop — making the outage worse, not better.
- Readiness — "Can this instance serve traffic right now?" This is where dependency checks belong. An instance that can't reach SQL Server should be pulled from rotation, but not killed.
Tags make this trivial to express:
// Liveness: cheap, no dependencies. Answers "is the process running?"
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
// Readiness: full dependency check. Answers "can I take traffic?"
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
In a Kubernetes deployment, wire them up like this:
// deployment.yaml (excerpt)
// livenessProbe:
// httpGet: { path: /health/live, port: 8080 }
// periodSeconds: 10
// failureThreshold: 3
// readinessProbe:
// httpGet: { path: /health/ready, port: 8080 }
// periodSeconds: 5
Returning Detailed JSON Responses
The default plain-text "Healthy" is fine for machines but useless for humans debugging an incident. The ResponseWriter option lets you emit rich JSON:
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
durationMs = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
data = e.Value.Data
})
}, new JsonSerializerOptions { WriteIndented = true });
await context.Response.WriteAsync(result);
}
});
Now a failing readiness probe tells you exactly which dependency failed and how long each check took — the difference between a 5-minute fix and an hour of guessing.
Health Checks UI: A Free Monitoring Dashboard
The AspNetCore.HealthChecks.UI package gives you a polling dashboard with history and webhook alerts (Slack, Teams, email) — no third-party APM subscription required:
// dotnet add package AspNetCore.HealthChecks.UI
// dotnet add package AspNetCore.HealthChecks.UI.InMemory.Storage
builder.Services.AddHealthChecksUI(options =>
{
options.SetEvaluationTimeInSeconds(30); // poll every 30s
options.SetMinimumSecondsBetweenFailureNotifications(300);
options.AddHealthCheckEndpoint("My API", "/health/ready");
}).AddInMemoryStorage();
// ...
app.MapHealthChecksUI(options => options.UIPath = "/health-ui");
Browse to /health-ui and you get a live dashboard showing every check, its status, and its failure history. For teams that already run Prometheus or Datadog, the JSON endpoint above integrates directly instead.
Best Practices and Common Pitfalls
Best practices
- Keep liveness checks dependency-free. A liveness failure means "restart me." Only report unhealthy for problems a restart can actually fix, such as a deadlocked thread pool.
- Set timeouts on every check. A health check that hangs for 30 seconds waiting on a dead database will cause probe timeouts and cascading restarts. Two to five seconds is a sensible ceiling.
- Use Degraded deliberately. Reserve Unhealthy for "cannot serve users." Cache misses, slow-but-working queries, and optional integrations belong in Degraded so dashboards show trouble without triggering restarts.
- Secure detailed endpoints. Your JSON response leaks infrastructure details (server names, dependency lists). Expose
/health/livepublicly for probes, but protect the detailed endpoint:app.MapHealthChecks("/health/detail", options).RequireAuthorization("OpsPolicy");or restrict it to an internal port withRequireHost("*:5001"). - Make checks cheap. Probes fire every few seconds across every instance. A SQL check should run
SELECT 1, not a full table scan. If a check is inherently expensive, cache its result for 10–30 seconds inside the check itself.
Common pitfalls
- Putting the database in the liveness probe. The classic mistake: database blips, Kubernetes restarts all pods simultaneously, and a 30-second outage becomes a 10-minute one.
- Health-checking dependencies you don't own the fix for. If a third-party API is down and your app can queue work and retry, reporting Unhealthy takes your app down over their outage.
- Forgetting that checks run on the request thread. Blocking calls (
.Result,.Wait()) insideCheckHealthAsynccan starve the thread pool under load — always use async I/O. - One giant
/healthendpoint for everything. Different consumers need different answers. Load balancers, orchestrators, and humans should hit different endpoints filtered by tags.
Conclusion: Monitor Like a Pro with ASP.NET Core Health Checks
ASP.NET Core health checks turn "we found out from a customer" into "the load balancer routed around it automatically." They're built into the framework, cost almost nothing to add, and pay for themselves during the very first incident.
Key takeaways:
- Start with
AddHealthChecks()andMapHealthChecks("/health")— two lines gets you a working endpoint. - Add prebuilt checks for SQL Server, Redis, and external APIs; write
IHealthCheckimplementations for anything custom. - Split liveness (process alive, no dependencies) from readiness (can serve traffic, all dependencies) using tags — this one pattern prevents restart storms.
- Map failure status to business impact:
Unhealthymeans "pull me out of rotation,"Degradedmeans "watch me." - Emit detailed JSON for humans, secure it behind authorization, and use the free Health Checks UI dashboard for at-a-glance monitoring.
Add a health check endpoint to your ASP.NET Core app today — your future 2 AM self will thank you.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment