Health Checks in ASP.NET Core Web API: Liveness, Readiness and Database Checks
Health checks in ASP.NET Core Web API: a database check with AddDbContextCheck, a Redis check with IHealthCheck, separating liveness and readiness, why Degraded returns 200, JSON output and timeout pitfalls.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the previous post we connected Redis to the API. Now it is not enough for the app to be up; it also has to be able to reach the database and Redis. In this post we will build the endpoints that report this to the outside world.
In ASP.NET Core every health check returns one of three results: Healthy, Degraded or Unhealthy. Now guess: if one of the checks behind the /health endpoint returns Degraded, which HTTP status code does the endpoint return? And if one returns Unhealthy? A hint: the answers are not the same, and one of them surprises a lot of people.
Who Needs a Health Check?
When you run an API on its own, on a single server, you are the one asking "is it up?". In production, machines ask that question. The load balancer asks to decide which instance gets traffic; Kubernetes or a similar orchestrator asks to decide which container to restart; monitoring tools ask to decide whether to page you at two in the morning.
All of these machines want the same thing: an endpoint they can call at regular intervals, that answers fast, and whose answer can be read from the status code. There is an important distinction here: "the process is running" and "it can do its job" are not the same thing. Kestrel may be accepting requests, but if the database connection is gone, every product request returns a 500. A good health check makes that difference visible. To use a hospital analogy: whether there is a pulse is one question, whether the patient can go into surgery is another.
A First Endpoint: AddHealthChecks and MapHealthChecks
The simplest version is two lines:
builder.Services.AddHealthChecks();
// After app.Build()
app.MapHealthChecks("/health");
Since you haven't registered any checks, the result is always Healthy. Even this is useful, because getting an answer shows that the app can take requests. Look at the response carefully:
curl -i http://localhost:5305/health
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 03 Nov 2026 21:33:39 GMT
Server: Kestrel
Cache-Control: no-store, no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Transfer-Encoding: chunked
Healthy
The body is plain text, a single word. The middleware adds the cache headers by itself; you don't want a proxy like the one in the previous post to cache the health status and serve a stale answer.
Checking the Database: AddDbContextCheck
The real value is in checking real dependencies. If you use EF Core, Microsoft has a ready-made package. Pick its version by target framework: 6.0.36 for net6.0, 10.0.14 for net10.0.
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore --version 6.0.36
We use SQLite as the database in this post; our AppDbContext in its simplest form looks like this:
using Microsoft.EntityFrameworkCore;
namespace ProductCatalog.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Product> Products => Set<Product>();
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Registering the check is one line: .AddDbContextCheck<AppDbContext>("database"). Behind the scenes it calls Database.CanConnectAsync(); if it can connect the result is Healthy, if not, Unhealthy. It doesn't run a query or look at tables, it only asks "can I talk to it?".
We tested this without meaning to on the first try. The connection string was Data Source=catalog.db, but we hadn't created the file yet. When the file is missing, the SQLite provider treats the database as "not there", and the check returned Unhealthy. Once the file existed, which in a real project happens the moment you apply the migration, the result became Healthy. So this check also catches the "I deployed but forgot the migration" case.
Writing Your Own Health Check: IHealthCheck
We want a check for Redis too. There are ready-made packages, which we will touch on shortly, but writing your own check is both easy and instructive. It is an interface with a single method:
using System.Diagnostics;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ProductCatalog.Api.Health;
public class CacheHealthCheck : IHealthCheck
{
private readonly IDistributedCache _cache;
private readonly int _slowThresholdMs;
public CacheHealthCheck(IDistributedCache cache, IConfiguration configuration)
{
_cache = cache;
_slowThresholdMs = configuration.GetValue("HealthChecks:CacheSlowThresholdMs", 200);
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Reading a missing key is enough: if an answer comes back, Redis is up
await _cache.GetStringAsync("health:probe", cancellationToken);
}
catch (Exception ex)
{
// Use the failureStatus given at registration (Unhealthy by default)
return new HealthCheckResult(context.Registration.FailureStatus, "Cache is unreachable.", ex);
}
var elapsedMs = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object> { ["elapsedMs"] = elapsedMs };
return elapsedMs >= _slowThresholdMs
? HealthCheckResult.Degraded("Cache is responding slowly.", data: data)
: HealthCheckResult.Healthy("Cache is responding.", data);
}
}
We use all three states. If we can't reach Redis, the result is the FailureStatus given at registration; taking it from the context instead of hard-coding Unhealthy lets whoever registers the check say "this isn't critical for me, count it as Degraded". If Redis answers but slower than the threshold, the result is Degraded: it works, but something is off. If everything is fine, Healthy. The data dictionary carries extra information with the result; we will see it in the JSON output shortly.
Since the check is created through DI, you can take any service you like in its constructor. We register it together with the others:
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddDbContextCheck<AppDbContext>("database", tags: new[] { "ready" })
.AddCheck<CacheHealthCheck>("cache", tags: new[] { "ready" });
Separating Liveness and Readiness
The tags in the registrations are there for a reason. Orchestrators usually ask two separate questions. Liveness: "Is this process still alive, or is it stuck? Should I restart it?" Readiness: "Can it take traffic right now? Should I add it to the load balancer?" The answers should come from different checks:
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = HealthResponseWriter.WriteJsonAsync
});
Predicate is the filter that picks which checks run on that endpoint. Liveness has only self, and that is nothing more than a lambda: if you can answer, you are alive. Readiness has the database and the cache. Stop the Redis container and call both endpoints, and the difference shows up:
curl -s -w " %{http_code}\n" http://localhost:5301/health/live
Healthy 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5301/health/ready
503
This is exactly the right behavior. Restarting the process because Redis went down fixes nothing; the new process can't reach Redis either. But it makes sense to stop sending traffic to that instance.
Here is the answer to the guess, too. The default mapping is: Healthy 200, Degraded also 200, Unhealthy 503. We tried it by pulling the threshold down to zero with the HealthChecks__CacheSlowThresholdMs=0 environment variable: the cache check returned Degraded, /health said Degraded in the body, but the status code stayed 200. The reasoning is that Degraded means "I'm working, but watch out", which isn't considered reason enough to cut traffic. If in your scenario it is, you can change the mapping:
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = HealthResponseWriter.WriteJsonAsync,
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
});
With this version, the same experiment made /health/ready return 503.
JSON Output
A status code is enough for the load balancer, but when a human asks "why 503?", one word is not enough. With ResponseWriter you can write the output however you like:
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ProductCatalog.Api.Health;
public static class HealthResponseWriter
{
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public static Task WriteJsonAsync(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(entry => new
{
name = entry.Key,
status = entry.Value.Status.ToString(),
description = entry.Value.Description,
durationMs = entry.Value.Duration.TotalMilliseconds,
error = entry.Value.Exception?.Message,
data = entry.Value.Data
})
};
return context.Response.WriteAsync(JsonSerializer.Serialize(payload, Options));
}
}
/health/ready while Redis is up:
{
"status": "Healthy",
"totalDurationMs": 2.7468,
"checks": [
{
"name": "database",
"status": "Healthy",
"description": null,
"durationMs": 2.4839,
"error": null,
"data": {}
},
{
"name": "cache",
"status": "Healthy",
"description": "Cache is responding.",
"durationMs": 0.5245,
"error": null,
"data": {
"elapsedMs": 0
}
}
]
}
The overall status is the status of the worst check. The duration of each check is visible separately too; this is where you catch a dependency that is slowing down. If you want a ready-made format, UIResponseWriter.WriteHealthCheckUIResponse in the AspNetCore.HealthChecks.UI.Client package produces a similar JSON. The same family has ready-made checks for Redis, SQL Server and PostgreSQL (such as AspNetCore.HealthChecks.Redis). Watch the version when you pick one: in this family the line that targets net6.0 is 6.0.x, while the newest 9.0.0 releases target net8.0.
Common Pitfalls
Putting a database check into liveness is the most expensive mistake. If the database stops answering for a few seconds, the orchestrator restarts every instance at once; and when the database comes back, the cold-starting apps all pile onto it at the same moment. Keep liveness simple, put dependencies into readiness.
A slow check is worse than no check. With Redis down, the readiness answer came back right away in the net6.0 project with the 6.0.36 cache package (totalDurationMs: 115), but in the net10.0 project, that is with the 10.0.14 package, it took 6 seconds; the error message said "The message timed out in the backlog ... (5000ms)". Most probes won't wait that long. You can give the check a timeout:
.AddCheck<CacheHealthCheck>("cache", tags: new[] { "ready" }, timeout: TimeSpan.FromSeconds(2));
But on its own it didn't help: it still took 6 seconds, because the timeout works through the cancellation token and the Redis call doesn't listen to the token. Once we wrapped the call inside the check with .WaitAsync(cancellationToken) (a method that came with .NET 6), the answer arrived in 2.03 seconds and the error became "A task was canceled.":
await _cache.GetStringAsync("health:probe", cancellationToken).WaitAsync(cancellationToken);
The JSON output leaks information. In the net10.0 project, with Redis down, the error field contained the server address, the port and the client version. Don't expose the detailed endpoint to the internet: bind it with .RequireHost(...) to a host that only internal traffic uses, or serve it on a separate port; if you must expose something publicly, expose the plain endpoint that only returns a status code.
Finally, the health endpoint is affected by the rest of the pipeline. If you use a fallback authorization policy, probes get a 401; you need to add .AllowAnonymous() to the endpoint. HTTPS redirection can also bounce a probe that comes in over HTTP with a 307; check which protocol your probe uses.
A Small Experiment
★☆☆ Add failureStatus: HealthStatus.Degraded to the CacheHealthCheck registration. Stop Redis and see that /health/ready now returns 200 instead of 503, with Degraded in the body.
★★☆ Set the connection string to Data Source=/nonexistent/catalog.db and start the app with an environment variable. Inspect the result of the database check and its description field; look into the customTestQuery parameter of AddDbContextCheck, which lets you supply your own test query.
★★★ Restrict /health/ready with .RequireHost("localhost:5301"), then call the same endpoint through 127.0.0.1:5301 and see the 404. Then set up a layout with a separate, public endpoint for liveness and a restricted one for readiness.
Health checks measure the pulse of the running app. But how do we know the code we wrote works correctly before we deploy, before we even start the app? In the next post we start writing unit tests with xUnit and Moq.