ASP.NET Core Health Checks on .NET 10: Setup and Gotchas
Last updated: October 1, 2026 · Tested with .NET 10 (SDK 10.0.201), AspNetCore.HealthChecks.* 9.0.0, EF Core 10.0.12, SQL Server LocalDB
Short answer: call builder.Services.AddHealthChecks(), chain one check per dependency, and expose it with app.MapHealthChecks("/health"). The endpoint returns Healthy with HTTP 200, or Unhealthy with HTTP 503. For real deployments you want two endpoints (liveness without dependencies, readiness with them), a JSON body for humans, and timeouts on every check. I built all of that in one .NET 10 app and hit it with curl. Every response below is real output, and three results surprised me: a 2-second timeout that let a check run for 6 seconds and still report Healthy, Degraded returning 200, and exception messages showing up in the public JSON.
The Minimum Setup
Health checks are part of ASP.NET Core itself (Microsoft.Extensions.Diagnostics.HealthChecks is in the shared framework), so the basic version needs no NuGet package:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
$ curl -i http://localhost:5080/health
HTTP/1.1 200 OK
Content-Type: text/plain
Healthy
With no checks registered this only proves the process can answer HTTP. That is exactly what a liveness probe should test, and nothing more. Everything useful comes from adding checks.
One thing to know: calling MapHealthChecks without AddHealthChecks does not fail at compile time. It throws at startup:
Unhandled exception. System.InvalidOperationException: Unable to find the required services.
Please add all the required services by calling 'IServiceCollection.AddHealthChecks' inside
the call to 'ConfigureServices(...)' in the application startup code.
If you split service registration into extension methods, this is an easy one to lose.
Adding Checks for SQL Server, EF Core and an HTTP Dependency
The ready-made checks come from the open-source AspNetCore.Diagnostics.HealthChecks project (the AspNetCore.HealthChecks.* packages). The EF Core check is Microsoft’s own package.
dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.Uris
dotnet add package AspNetCore.HealthChecks.UI.Client
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
var db = builder.Configuration.GetConnectionString("Db")!;
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(db));
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddSqlServer(db, name: "sql",
timeout: TimeSpan.FromSeconds(3), tags: ["ready"])
.AddDbContextCheck<AppDbContext>("efcore", tags: ["ready"])
.AddUrlGroup(new Uri("http://localhost:5080/payment/status"), name: "payment-api",
failureStatus: HealthStatus.Degraded,
timeout: TimeSpan.FromSeconds(2), tags: ["ready"]);
AddSqlServeropens a connection and runsSELECT 1;(you can pass your ownhealthQuery).AddDbContextCheckcallsDatabase.CanConnectAsync()on your context, so it also catches a wrong connection string in the context registration.AddUrlGroupsends a GET and treats any 2xx as healthy. In the demo it calls a fake payment provider inside the same app that I can switch off.failureStatus: HealthStatus.Degradedon the payment check means “we can still take orders without it”. KeepUnhealthyfor dependencies without which the app is useless.
Liveness vs Readiness: Two Endpoints, Filtered by Tag
// Liveness: no dependencies. Failing this restarts the container.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("live")
});
// Readiness: dependencies, with a JSON body.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
Real responses with everything up:
$ curl http://localhost:5080/health/live
Healthy [HTTP 200, 0.009 s]
$ curl http://localhost:5080/health/ready
{"status":"Healthy","totalDuration":"00:00:00.1635204","entries":{
"sql": {"duration":"00:00:00.1114389","status":"Healthy","tags":["ready"]},
"efcore": {"duration":"00:00:00.1247102","status":"Healthy","tags":["ready"]},
"payment-api": {"duration":"00:00:00.1571213","status":"Healthy","tags":["ready"]}}}
[HTTP 200, 0.57 s]
(Formatted for reading; the empty data objects are removed.) Note the durations: 111 + 125 + 157 ms of checks finished in 164 ms total. Checks run in parallel, so the endpoint is as slow as its slowest check, not the sum.
Why two endpoints? If the database blips and your liveness probe includes the database check, Kubernetes restarts every pod at the same moment, and they all come back to the same broken database. With the split, readiness fails, the pods leave the load balancer, and they rejoin on their own when the database recovers.
UIResponseWriter comes from the AspNetCore.HealthChecks.UI.Client package, not from the UI package. Many examples use it without listing that package, and you get a “name does not exist” error.
Degraded Returns HTTP 200 (Probably Not What You Want)
I switched the fake payment provider off (it returns 503) and called readiness again:
$ curl http://localhost:5080/health/ready
{"status":"Degraded", ... "payment-api":{"description":"Discover endpoint #0 is not
responding with code in 200...299 range, the current status is ServiceUnavailable.",
"status":"Degraded"}}
[HTTP 200, 0.19 s]
The status says Degraded, but load balancers and Kubernetes only read the HTTP code, and that is still 200. By default Healthy and Degraded both map to 200, and only Unhealthy maps to 503. If a degraded instance should leave rotation, map it yourself:
app.MapHealthChecks("/health/ready-strict", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
});
$ curl http://localhost:5080/health/ready-strict
Degraded [HTTP 503, 0.02 s]
Decide per endpoint. For the payment example I would keep the instance in rotation (200) and alert on Degraded instead, because every instance sees the same provider outage and pulling all of them helps nobody.
Writing a Custom Health Check
Implement IHealthCheck and register it with AddCheck<T>. Checks are resolved from DI on every run, so you can inject IHttpClientFactory, a DbContext or anything else.
public class PaymentGatewayHealthCheck(IHttpClientFactory factory) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
var client = factory.CreateClient("PaymentGateway");
try
{
using var response = await client.GetAsync("/api/status", cancellationToken);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: new HealthCheckResult(context.Registration.FailureStatus,
$"Payment gateway returned {(int)response.StatusCode}");
}
catch (HttpRequestException ex)
{
return new HealthCheckResult(context.Registration.FailureStatus,
"Payment gateway unreachable", ex);
}
}
}
builder.Services.AddHealthChecks()
.AddCheck<PaymentGatewayHealthCheck>("payment-gateway",
failureStatus: HealthStatus.Degraded,
timeout: TimeSpan.FromSeconds(2),
tags: ["ready"]);
Two details that matter. Return context.Registration.FailureStatus instead of hard-coding Unhealthy, so the failureStatus you choose at registration actually takes effect. And pass cancellationToken to every async call, for the reason in the next section.
Gotcha: The Timeout Only Works If Your Check Honors the Token
I registered two checks with timeout: TimeSpan.FromSeconds(2) that both wait 6 seconds. The only difference is whether the token is passed on:
class IgnoresTokenCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext c, CancellationToken ct = default)
{
await Task.Delay(TimeSpan.FromSeconds(6)); // token not passed
return HealthCheckResult.Healthy("finished after 6 s");
}
}
class HonorsTokenCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext c, CancellationToken ct = default)
{
await Task.Delay(TimeSpan.FromSeconds(6), ct); // token passed
return HealthCheckResult.Healthy("finished after 6 s");
}
}
$ curl http://localhost:5080/health/slow
{"status":"Unhealthy","totalDuration":"00:00:06.0092771","entries":{
"ignores-token":{"description":"finished after 6 s",
"duration":"00:00:06.0085612","status":"Healthy"},
"honors-token": {"description":"A timeout occurred while running check.",
"duration":"00:00:02.0066903","status":"Unhealthy"}}}
[HTTP 503, 6.01 s]
The framework does not abort a check when the timeout expires. It cancels the token it passed in. A check that ignores the token runs to the end, the request takes 6 seconds, and the check even reports Healthy despite the 2-second limit. If your probe’s own timeout is shorter (Kubernetes defaults to 1 second), the probe fails while your app logs nothing useful. Pass the token to HttpClient, OpenAsync, ExecuteScalarAsync and every other call inside a check.
The packaged checks do honor it. I pointed AddSqlServer at an address with no server and a 3-second timeout:
$ curl http://localhost:5080/health/bad
{"status":"Unhealthy","totalDuration":"00:00:03.0647765","entries":{
"sql-wrong-server":{"description":"A task was canceled.","exception":"A task was canceled.",
"duration":"00:00:03.0128272","status":"Unhealthy"}}}
[HTTP 503, 3.07 s]
It stopped at 3 seconds instead of the 15-second SqlClient connect timeout. The message is “A task was canceled.” rather than the framework’s timeout text, because the package catches the exception itself. Don’t write alerts that match on the exact message text.
Gotcha: Exception Messages End Up in Your JSON
When a check throws, the framework turns it into the registration’s failure status and keeps the exception. UIResponseWriter then writes the message into the response:
$ curl http://localhost:5080/health/throws
{"status":"Unhealthy","entries":{"throws":{
"description":"Redis connection string 'cache-prod-01:6380,password=...' is invalid",
"exception":"Redis connection string 'cache-prod-01:6380,password=...' is invalid",
"status":"Unhealthy"}}} [HTTP 503, 0.007 s]
Driver exceptions often contain host names, database names or user names. Keep the public endpoint to the status word (the default writer does exactly that) and put the JSON on an endpoint the internet cannot reach. The simplest way I know is a separate management port:
// app started with --urls "http://localhost:5080;http://localhost:8081"
app.MapHealthChecks("/health/detail", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
}).RequireHost("*:8081");
$ curl http://localhost:5080/health/detail
[HTTP 404]
$ curl http://localhost:8081/health/detail
{"status":"Healthy","totalDuration":"00:00:00.0054690", ...} [HTTP 200]
Expose only port 5080 through your ingress or load balancer and the detail endpoint is not reachable from outside. .RequireAuthorization() works too, but probes from Kubernetes or a load balancer usually cannot send a token, so use it only on endpoints that people read.
Results Are Not Cached
Every request runs every matching check again. I sent 20 parallel requests to /health/ready and counted calls on the fake payment API: 6 before, 26 after, one per request. Ten instances probed every 10 seconds by two load balancers is 120 database round trips a minute from health checks alone. Keep checks cheap (SELECT 1, not a report query), and if a dependency charges per call or is rate-limited, cache its result inside your custom check for a few seconds.
The same applies the other way: if you add the ASP.NET Core rate limiter globally, exclude the health endpoints with .DisableRateLimiting(), or your probes start getting 429s under load and healthy pods get pulled. With a global limiter of one request per minute, a second app gave me this:
app.MapHealthChecks("/health/none", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") })
.DisableRateLimiting();
GET /health/none -> 429
GET /health/ready -> 200 (three times in a row)
Kubernetes Probes
The two endpoints map directly to probe types. A startup probe on the readiness endpoint gives a slow-starting app time to run migrations or warm caches before liveness checking begins:
startupProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 30 # up to 150 s to start
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 10
timeoutSeconds: 3
Keep every check timeout below the probe’s timeoutSeconds, otherwise Kubernetes gives up before your check can report anything. The Kubernetes default is 1 second. I did not run this on a cluster for this article; it follows the Kubernetes probe documentation. For a full deployment walkthrough see deploying .NET microservices to AKS.
Pushing Results Instead of Waiting to Be Polled
Register an IHealthCheckPublisher and the framework runs your checks on a timer in the background, whether or not anyone calls the endpoint:
builder.Services.Configure<HealthCheckPublisherOptions>(o =>
{
o.Delay = TimeSpan.FromSeconds(2); // default 5 s after startup
o.Period = TimeSpan.FromSeconds(10); // default 30 s
o.Predicate = c => c.Tags.Contains("ready");
});
builder.Services.AddSingleton<IHealthCheckPublisher, LogPublisher>();
class LogPublisher(ILogger<LogPublisher> log) : IHealthCheckPublisher
{
public Task PublishAsync(HealthReport report, CancellationToken ct)
{
log.LogInformation("Published {Status} in {Ms:F0} ms: {Entries}",
report.Status, report.TotalDuration.TotalMilliseconds,
string.Join(", ", report.Entries.Select(e => $"{e.Key}={e.Value.Status}")));
return Task.CompletedTask;
}
}
info: LogPublisher[0]
Published Healthy in 5 ms: sql=Healthy, efcore=Healthy, payment-api=Healthy
info: LogPublisher[0]
Published Healthy in 4 ms: sql=Healthy, efcore=Healthy, payment-api=Healthy
Replace the logger with a metrics call and you get health history in your dashboards without an external poller. Since .NET 8, HealthCheckRegistration also has its own Delay and Period, so an expensive check can be published less often than the rest. If you already export metrics with OpenTelemetry, this is the natural place to emit a health gauge.
You Get Failure Logs for Free
The built-in DefaultHealthCheckService already logs every non-healthy result. From the run above, with no logging code of my own:
warn: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103]
Health check payment-api with status Degraded completed after 10.885ms with message
'Discover endpoint #0 is not responding with code in 200...299 range, the current status is ServiceUnavailable.'
fail: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[104]
Health check honors-token threw an unhandled exception after 2006.6903ms
Make sure your production log level does not filter out the Microsoft.Extensions.Diagnostics.HealthChecks category. Those lines are often the first record of an outage.
What About the Health Checks UI Dashboard?
AspNetCore.HealthChecks.UI adds a web page that polls your endpoints and keeps a status history. It is handy in development and staging. In production most teams already have Grafana, Datadog or Application Insights, and a publisher (above) feeding those is less to run and secure than another UI. I did not include it in this test.
Checklist
- Liveness endpoint with no dependency checks; readiness endpoint with them.
- A
timeouton every check, shorter than the probe timeout, and the token passed through in custom checks. - Decide whether Degraded should be 200 or 503 for each endpoint.
- Status word only on public endpoints; JSON on a management port or behind auth.
- Cheap checks, because nothing is cached.
- Health endpoints excluded from rate limiting.
- Return
context.Registration.FailureStatusfrom custom checks.
FAQ
Do I need a NuGet package for health checks in ASP.NET Core? Not for AddHealthChecks and MapHealthChecks; they are in the framework. You need packages for ready-made dependency checks (SQL Server, Redis, URLs and so on) and for the EF Core AddDbContextCheck.
What HTTP status does a health check return? 200 for Healthy and Degraded, 503 for Unhealthy. Change it with HealthCheckOptions.ResultStatusCodes.
Do health checks run in parallel? Yes. The total time is roughly the slowest check, as the 164 ms total for three checks above shows.
How do I run only some checks on an endpoint? Give checks tags at registration and filter with HealthCheckOptions.Predicate. Predicate = _ => false runs no checks at all, which is another way to build a liveness endpoint.
Are health check results cached? No. Each request runs the checks again. Only a publisher runs them on a schedule.
Conclusion
The setup is three lines. The details that decide whether health checks help you in an outage are the ones above: split liveness from readiness, put a timeout on every check and make custom checks respect it, choose what Degraded means for your load balancer, and keep error details off public endpoints. If you are building several services, the .NET microservices guide shows these endpoints behind a YARP gateway, and Docker for .NET developers covers wiring them into container health checks.
References: Microsoft: Health checks in ASP.NET Core · AspNetCore.Diagnostics.HealthChecks on GitHub · Kubernetes: liveness, readiness and startup probes
This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).