ASP.NET Core Health Checks: Liveness Probe Timeout Restarts
When database slowed, liveness probe timed out in 5s, causing all pods to restart in a minute.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- ASP.NET Core health checks let external systems (K8s, load balancers) query app and dependency status in a standard way.
- Split liveness (pod alive) and readiness (can serve traffic) onto separate endpoints using tag filtering.
- Custom IHealthCheck classes with timeouts prevent hung checks from blocking probes – always fail fast.
- HTTP status mapping via ResultStatusCodes controls how Degraded vs Unhealthy affects infrastructure decisions.
- The Health Checks UI dashboard gives ops teams visual history and drill-down per check.
- Never put database checks on the liveness probe – that causes restart storms during partial outages.
Imagine a hospital with a dashboard showing every patient's vital signs — heart rate, blood pressure, oxygen — all on one screen. A doctor glances at it and instantly knows who needs attention. ASP.NET Core health checks are exactly that dashboard for your application. Instead of patients, you're monitoring your database connection, your message queue, your disk space, and any other system your app depends on. One endpoint, one glance, and you know if everything is healthy or something is about to crash.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
When your app is running in production, 'it deployed successfully' is just the beginning. Kubernetes needs to know whether to send traffic to your pod. Your load balancer needs to decide if an instance should be taken out of rotation. Your ops team needs an alert before a full outage hits — not after. Without a structured health check system, you're flying blind. You're relying on a user to tell you something is broken, which is the worst possible monitoring strategy.
Health checks solve a specific, painful problem: how do external systems and internal teams get a reliable, machine-readable signal about whether your application and all of its dependencies are functioning correctly? Before ASP.NET Core 2.2, teams would hand-roll ping endpoints, scatter try-catch blocks across random controllers, and end up with inconsistent, unreliable status pages. The built-in health check middleware standardises all of that — with a clean model for registering checks, aggregating results, and exposing them over HTTP.
By the end of this article you'll know how to register built-in and custom health checks, gate them by tags for different audiences (liveness vs readiness), wire up the visual Health Checks UI dashboard, and avoid the three mistakes that catch almost every developer the first time. You'll also have copy-paste-ready code patterns you can drop into a real project today.
Why ASP.NET Core Health Checks Are Not Optional for Liveness Probes
ASP.NET Core health checks expose endpoints that return the operational status of your application — typically as a 200 OK or 503 Service Unavailable. The core mechanic is simple: you register one or more checks (e.g., database connectivity, disk space, external API reachability) and the middleware aggregates their results into a single response. Kubernetes liveness probes call this endpoint to decide whether to restart the pod.
The critical property that bites teams: health checks have a configurable timeout. If a check hangs (e.g., a database query deadlocks or an HTTP call to a downstream service times out after 30 seconds), the entire health check endpoint can block for that duration. Kubernetes liveness probes have their own timeout (default 1 second). When the health check exceeds the probe timeout, Kubernetes marks the pod as unhealthy and restarts it — even if the app is perfectly fine. This creates a restart loop that can cascade across replicas.
You need health checks when running in an orchestrator like Kubernetes that relies on liveness probes for self-healing. Without them, a deadlocked thread or a slow dependency can silently degrade your service. With them, you get automatic recovery — but only if you set timeouts aggressively (e.g., 500ms) and avoid blocking operations inside checks. Use them to detect catastrophic failures, not to monitor latency.
How the Health Check Pipeline Actually Works
Before writing a single line of code, it's worth understanding the architecture — because once you see it, every API decision makes sense.
ASP.NET Core's health check system has three layers. First, you register one or more IHealthCheck implementations with the DI container via AddHealthChecks(). Each check is a small class with a single method — CheckHealthAsync — that returns a HealthCheckResult of Healthy, Degraded, or Unhealthy.
Second, the framework aggregates those results. When the health endpoint is hit, it runs all registered checks (or a filtered subset by tag), collects every result, and computes an overall status. If any check is Unhealthy, the aggregate is Unhealthy. If any is Degraded but none are Unhealthy, the aggregate is Degraded.
Third, the middleware serialises that result and returns an HTTP response. By default it just writes 'Healthy' or 'Unhealthy' as plain text. But you can swap in a custom response writer to return rich JSON — which is exactly what production systems need.
The key insight here is separation of concerns: the check logic, the aggregation logic, and the serialisation logic are all independent. That's what makes the system so composable.
Writing a Real Custom Health Check — Database + External API
The built-in lambda-style checks are fine for demos, but production systems need proper IHealthCheck implementations. This is where the pattern gets genuinely powerful.
A well-written health check does three things: it detects a real failure condition (not just 'can I reach the host'), it includes diagnostic data in the result so engineers can debug without reading logs, and it fails fast — it has a timeout so a slow dependency doesn't hold up your entire health endpoint.
Let's build two concrete examples: a SQL Server check that validates query execution (not just connection), and an external HTTP API check that confirms the downstream service is actually responding correctly.
Notice the pattern in both checks: the try/catch returns Unhealthy with the exception message as the description. That description surfaces in the JSON response, which means your on-call engineer sees the actual error message — not just a red dot on a dashboard.
new HttpClient() inside CheckHealthAsync is a classic socket exhaustion bug — health checks run frequently (every few seconds in K8s), so you'll blow through available sockets fast. Always inject IHttpClientFactory and call CreateClient(). It's one extra line of setup in Program.cs and it eliminates the entire problem.Custom JSON Response Writer and the Health Checks UI Dashboard
The default health check response is a single word — 'Healthy' or 'Unhealthy'. That's fine for Kubernetes probes, but it's useless for a human engineer trying to diagnose a problem. You need a JSON response that includes every check name, its status, its description, and how long it took.
ASP.NET Core lets you swap in a custom ResponseWriter — a delegate of type Func<HttpContext, HealthReport, Task>. You write it once, pass it to every HealthCheckOptions instance, and every endpoint automatically returns rich JSON.
For a visual dashboard, the AspNetCore.HealthChecks.UI NuGet package gives you a ready-made React UI that polls your health endpoints and shows a live status board. It's genuinely useful for ops teams — and it takes about ten minutes to set up.
The UI package needs a separate configuration section in appsettings.json that lists the health check URIs to monitor. This means the UI can monitor multiple services, not just the current app — making it a lightweight centralised health dashboard.
app.MapHealthChecksUI().RequireAuthorization('InternalOnly') with an IP-restriction policy. Exposing it publicly is a real security risk.HTTP Status Codes, Failure Thresholds and the ResultStatusCodes Gotcha
Here's something that surprises almost everyone the first time: by default, ASP.NET Core returns HTTP 200 for both Healthy and Degraded results, and HTTP 503 only for Unhealthy. That means Kubernetes readiness probes — which interpret anything other than 2xx as a failure — won't remove a degraded pod from the load balancer. If 'degraded' for you means 'stop sending traffic here', you need to override this.
You control the HTTP status code mapping via HealthCheckOptions.ResultStatusCodes. It's a dictionary from HealthStatus to HTTP status code. Changing Degraded to map to 503 tells K8s to remove the pod from rotation when any check is degraded.
There's also the FailureStatus concept — set per check registration, not per endpoint. It controls what status gets reported when a check throws an exception or returns Unhealthy. Setting failureStatus: HealthStatus.Degraded on a non-critical check means that check can fail without taking the whole service offline.
These two levers together give you very fine-grained control over how dependency failures propagate to your infrastructure.
Health Checks for Background Services and Worker Processes
Not all work happens in request-response cycles. Your app probably runs background services — hosted services that process messages, poll queues, or perform periodic maintenance. If one of those workers stalls, the health endpoint should know about it, even if the main web process is still accepting requests.
The solution is to share state between your BackgroundService and an IHealthCheck implementation, usually via a thread-safe flag or a shared object registered as a singleton. The background service writes its status (last processed timestamp, queue depth, error count), and the health check reads it.
This pattern keeps the health check lightweight and decouples worker logic from health reporting. You get accurate visibility into background activity without making the health check itself execute business logic.
Separate Readiness from Liveness or Your Pods Will Cycle Forever
You cannot use the same health check endpoint for both readiness and liveness probes in any serious Kubernetes deployment. Liveness tells the orchestrator 'kill me and restart me'. Readiness says 'don't send traffic yet'. If your database goes down and both probes point to the same endpoint, Kubernetes kills the pod instead of just removing it from the service load balancer. That means a transient DB timeout becomes a full pod restart — and your team gets paged at 3 AM for a connection pool blip. The fix is cheap: two endpoints. One lightweight liveness check that only verifies the HTTP pipeline is alive (static file, in-memory, no dependencies). One full readiness check that pings your database, cache, and critical downstream APIs. ASP.NET Core supports this natively with separate MapHealthChecks calls. Map /healthz to a simple 'always healthy' check. Map /ready to your real dependency probes. Configure your orchestrator to use /ready for readiness and /healthz for liveness. Your SRE team will thank you.
The Docker HEALTHCHECK Command That Actually Prevents a CrashLoopBackOff
Your Dockerfile HEALTHCHECK command is not the same as Kubernetes probes. Docker runs this command inside the container every N seconds. If it fails three times in a row, Docker marks the container as unhealthy. Kubernetes sees that status and eventually kills the pod. The trap? Developers write HEALTHCHECK curl --fail http://localhost:5000/healthz without considering the startup grace period. On slow hardware or when the database is cold-starting, that curl fails for the first 10 seconds. Docker decides the container is unhealthy immediately. Kubernetes gets confused. Now you have a CrashLoopBackOff that is actually just a slow start. Fix it with a retry loop and a generous --retry flag. Or better, write a health check endpoint that returns 503 until the app signals ready. Then HEALTHCHECK can use a simple curl with --retry-connrefused and --retry 5. This gives your app time to warm up EF Core connections, prime caches, and validate database access before the orchestrator decides to kill it.
Readiness and Liveness Probes for Kubernetes
In Kubernetes, health checks are configured via probes that determine how your application is treated. Liveness probes indicate whether the container is running; if they fail, Kubernetes restarts the pod. Readiness probes indicate whether the container is ready to serve traffic; if they fail, the pod is removed from service endpoints. It's critical to separate these concerns to avoid unnecessary restarts during startup or temporary unavailability.
For ASP.NET Core, you can expose different endpoints for liveness and readiness. A common pattern is to have /health/ready for readiness (checking dependencies like databases and caches) and /health/live for liveness (a simple check that the process is alive). Configure the health checks middleware accordingly:
```csharp app.UseHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false // No checks, just returns 200 if process is up });
app.UseHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = WriteResponse }); ```
In your Kubernetes deployment YAML, define probes referencing these endpoints:
``yaml livenessProbe: httpGet: path: /health/live port: 80 initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /health/ready port: 80 initialDelaySeconds: 10 periodSeconds: 5 ``
This separation ensures that a temporary database outage doesn't cause a pod restart, but only removes it from service rotation until the database recovers.
HealthCheckService for Programmatic Health Verification
ASP.NET Core provides the HealthCheckService class that allows you to programmatically run health checks from within your application. This is useful for scenarios like exposing health status via a custom API, triggering health checks on demand, or integrating with monitoring systems.
To use HealthCheckService, inject it into your controller or service:
```csharp [ApiController] [Route("api/[controller]")] public class HealthController : ControllerBase { private readonly HealthCheckService _healthCheckService;
public HealthController(HealthCheckService healthCheckService) { _healthCheckService = healthCheckService; }
[HttpGet] public async TaskGet() { var result = await _healthCheckService.CheckHealthAsync(); var status = result.Status == HealthStatus.Healthy ? "Healthy" : "Unhealthy"; return Ok(new { status, totalDuration = result.TotalDuration }); } } ```
You can also filter checks by tags or pass a cancellation token. For example, to run only checks tagged as "database":
``csharp var result = await _healthCheckService.CheckHealthAsync( check => check.Tags.Contains("database")); ``
This programmatic approach gives you full control over when and how health checks are executed, enabling custom reporting or conditional logic based on health status.
Custom Health Check Patterns: Database, Cache, External APIs
Real-world applications depend on various external services. Custom health checks allow you to verify each dependency's availability. Common patterns include checking databases, caches (like Redis), and external APIs.
Database Health Check
Use Entity Framework Core or raw ADO.NET to test connectivity. For example, a simple SQL Server health check:
```csharp public class SqlServerHealthCheck : IHealthCheck { private readonly string _connectionString;
public SqlServerHealthCheck(IConfiguration configuration) { _connectionString = configuration.GetConnectionString("DefaultConnection"); }
public async TaskOpenAsync(); using var command = connection.CreateCommand(); command.CommandText = "SELECT 1"; await command.ExecuteScalarAsync(); return HealthCheckResult.Healthy(); } catch (Exception ex) { return HealthCheckResult.Unhealthy("Database is not reachable", ex); } } } ```
Cache Health Check (Redis)
For Redis, use the I method:Database.PingAsync()
```csharp public class RedisHealthCheck : IHealthCheck { private readonly IConnectionMultiplexer _redis;
public RedisHealthCheck(IConnectionMultiplexer redis) { _redis = redis; }
public async Task_redis.GetDatabase(); await db.PingAsync(); return HealthCheckResult.Healthy(); } catch (Exception ex) { return HealthCheckResult.Unhealthy("Redis is not reachable", ex); } } } ```
External API Health Check
Use HttpClient to call a health endpoint of an external service:
```csharp public class ExternalApiHealthCheck : IHealthCheck { private readonly HttpClient _httpClient;
public ExternalApiHealthCheck(HttpClient httpClient) { _httpClient = httpClient; }
public async TaskHealthCheckResult.Healthy(); return HealthCheckResult.Degraded("API returned non-success status"); } catch (Exception ex) { return HealthCheckResult.Unhealthy("API is not reachable", ex); } } } ```
Register these checks in ConfigureServices:
``csharp services.``AddHealthChecks() .AddCheck
Tagging checks as "ready" allows you to use them only for readiness probes.
The Restart Storm That Took Down Three Services
- Liveness probes must only check if the process itself is alive, not its dependencies.
kubectl get pods --field-selector=status.phase=Running -o custom-columns=NAME:.metadata.name,LIVENESS:.spec.containers[0].livenessProbe.httpGet.pathcurl -w '%{http_code}' http://localhost:5000/healthz/live/healthz/live and configure that endpoint to run no checks (Predicate = _ => false).| File | Command / Code | Purpose |
|---|---|---|
| Program.cs | var builder = WebApplication.CreateBuilder(args); | How the Health Check Pipeline Actually Works |
| SqlServerHealthCheck.cs | using Microsoft.Extensions.Diagnostics.HealthChecks; | Writing a Real Custom Health Check |
| HealthCheckResponseWriter.cs | using System.Text.Json; | Custom JSON Response Writer and the Health Checks UI Dashboa |
| HealthCheckStatusCodeConfig.cs | var builder = WebApplication.CreateBuilder(args); | HTTP Status Codes, Failure Thresholds and the ResultStatusCo |
| WorkerHealthCheck.cs | public class BackgroundQueueProcessor : BackgroundService | Health Checks for Background Services and Worker Processes |
| Dockerfile | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base | The Docker HEALTHCHECK Command That Actually Prevents a Cras |
| Startup.cs | app.UseHealthChecks("/health/live", new HealthCheckOptions | Readiness and Liveness Probes for Kubernetes |
| HealthController.cs | [ApiController] | HealthCheckService for Programmatic Health Verification |
| SqlServerHealthCheck.cs | public class SqlServerHealthCheck : IHealthCheck | Custom Health Check Patterns |
Key takeaways
Interview Questions on This Topic
What's the difference between a liveness probe and a readiness probe, and how do ASP.NET Core health check tags help you implement both correctly?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's ASP.NET. Mark it forged?
8 min read · try the examples if you haven't