Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 047 / 100

Health checks that mean something in .NET & ASP.NET Core

A 200 OK from /health does not prove your app works.

Expona Admin7 min read

Health checks that mean something

A 200 OK response from /health tells you almost nothing about whether your application actually works. I've watched production incidents unfold where the load balancer kept routing traffic to instances because the health endpoint returned green—while the database was unreachable, Redis was down, and message queues were backing up. In this post, we'll build health checks that give you real signal instead of false confidence.

Why the default health check fails you

The simplest health check only verifies that the ASP.NET Core process is running. If the endpoint responds, Kubernetes or your load balancer assumes everything is fine. But that's like checking if a car's engine turns on without verifying the brakes work or the fuel tank has gas. You need to distinguish between two critical states: whether your process is alive, and whether it's ready to handle work.

In production systems I've operated, I've seen scenarios where:

  • The app process runs fine but can't connect to PostgreSQL after a network partition
  • Redis is misconfigured, causing cache writes to fail silently, but the app stays up
  • Kafka brokers are unreachable, so the background job queue stalls, but the HTTP server still responds
  • A database migration locks tables, blocking queries, but the health endpoint itself doesn't touch the database

Each of these situations should trigger different responses from your health checks. That's where liveness and readiness probes come in.

Liveness: is the process alive?

A liveness probe answers one question: is the process still running and responding to requests? It's the minimal check. In Kubernetes, if a liveness probe fails repeatedly, the platform restarts the container. You want this to be fast and cheap—no database queries, no external dependencies.

In practice, a liveness check does almost nothing except verify that your application's HTTP server is up. You might check if core in-memory services are responsive, but nothing more.

csharp
builder.Services.AddHealthChecks()
  .AddCheck("self", () => HealthCheckResult.Healthy("API is running"));

app.MapHealthChecks("/live", new HealthCheckOptions
{
    Predicate = reg => reg.Tags.Contains("live"),
    ResponseWriter = WriteResponse
});

That's it. The /live endpoint confirms the process hasn't crashed. If it's returning 200, the container is alive. Kubernetes won't restart it based on this probe alone, which is correct—a restart won't help if the real problem is a network partition or a stalled dependency.

Readiness: can it reach critical dependencies?

A readiness probe is where health checks become meaningful. It verifies whether your application can actually do useful work right now. Can it reach the database? Can it connect to Redis? Can it publish to message queues? If any of these fail, the application should report not ready, and the load balancer should stop sending requests.

Here's where the tagging pattern becomes essential:

csharp
builder.Services.AddHealthChecks()
  .AddNpgSql(
    connectionString, 
    name: "PostgreSQL",
    tags: new[] { "ready" }
  )
  .AddRedis(
    redisConnectionString,
    name: "Redis",
    tags: new[] { "ready" }
  )
  .AddRabbitMQ(
    rabbitMQUri,
    name: "RabbitMQ",
    tags: new[] { "ready" }
  )
  .AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" });

app.MapHealthChecks("/live", new HealthCheckOptions
{
    Predicate = reg => reg.Tags.Contains("live")
});

app.MapHealthChecks("/ready", new HealthCheckOptions
{
    Predicate = reg => reg.Tags.Contains("ready")
});

Now /live returns 200 as long as the process exists. /ready returns 200 only when PostgreSQL, Redis, and RabbitMQ are all reachable and responsive. This distinction matters enormously during deployments or infrastructure issues.

Real scenario: You're rolling out a new version, and the new instances start up faster than the database becomes ready after a restart. With readiness probes, the old instances continue handling traffic while new ones wait. Without them, your entire service goes down for seconds.

Separate endpoints for Kubernetes probes

Kubernetes defines two probe types: livenessProbe and readinessProbe. They call different endpoints, and they have different retry logic and thresholds. Mixing them into one endpoint means you're either restarting containers unnecessarily when dependencies fail temporarily, or you're keeping unhealthy instances in the load balancer.

In your Kubernetes deployment manifest, it looks like this:

csharp
// In your appsettings.json or environment config
livenessProbe:
  httpGet:
    path: /live
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 1

The readiness probe is checked more frequently and fails faster because a temporary connection issue to Redis probably means you shouldn't accept new requests. The liveness probe is more lenient because a single HTTP timeout doesn't mean the process is actually dead—network jitter happens.

I've seen teams configure both probes identically, which defeats the purpose. Or worse, they configure the readiness probe so strictly that it triggers constant restarts during normal traffic spikes when database connection pools are exhausted momentarily. That's not health checking, that's thrashing.

Add checks for critical dependencies only

Not every external service deserves a spot in your readiness probe. If your application can function reasonably well without connecting to an analytics service, an external payment provider, or a third-party logging system, don't block readiness on them.

Critical dependencies are typically:

  • Primary database – if you can't query it, you can't serve requests
  • Cache layer – if Redis is down, your app might run but performance degrades; depends on your architecture
  • Message queue – if you're a consumer service, you need it; if you're a web API that publishes events, maybe not
  • Authentication/authorization service – if it's a remote dependency and you have no fallback

Non-critical dependencies:

  • Analytics platforms
  • Third-party APIs you call asynchronously
  • Email or SMS gateways
  • Monitoring and tracing systems

If a third-party API is down, you might still serve cached data or queue the work for retry. Your application should remain ready. Here's how you might structure this:

csharp
builder.Services.AddHealthChecks()
  // Critical: block traffic if these fail
  .AddNpgSql(connectionString, tags: ["ready"])
  .AddRedis(redisConnection, tags: ["ready"])
  
  // Non-critical: log the status but don't block
  .AddUrlGroup(
    new Uri("https://payment-api.external.com/health"),
    name: "PaymentAPI",
    tags: ["startup"]
  );

app.MapHealthChecks("/ready", new HealthCheckOptions
{
    Predicate = reg => reg.Tags.Contains("ready")
});

// Optional: expose startup checks for observability
app.MapHealthChecks("/startup", new HealthCheckOptions
{
    Predicate = reg => reg.Tags.Contains("startup")
});

The /ready endpoint blocks on database and cache. The /startup endpoint is for optional diagnostics. Your orchestration platform uses /ready exclusively.

Common mistakes

Making readiness checks too slow

Health checks run frequently—every 5–10 seconds typically. If your database check takes 2 seconds to timeout, you're now burning significant latency. Set connection timeouts aggressively. 1–2 seconds maximum. If your dependency can't respond in that time, it's not healthy anyway.

Checking internal services that are never down

Don't add a health check for an in-process service or a cache you control in-memory. These don't fail independently. Focus on network dependencies: databases, queues, external APIs.

Using the same endpoint for liveness and readiness

I mentioned this but it bears repeating. One endpoint means both probes have identical behavior. Kubernetes can't distinguish between "the process crashed" and "the database is unreachable." You lose the benefits of both probe types.

Ignoring transient failures

A single failed database query doesn't mean your app is unhealthy. Network timeouts happen. Configure Kubernetes failure thresholds properly: maybe require 2 or 3 consecutive probe failures before declaring unhealthy. This prevents flapping during brief network blips.

Not testing probes during incidents

The time to discover your health check is useless is not during an outage. Before you deploy to production, kill your database or Redis in staging and verify the probes fail. Verify the app gets removed from the load balancer. Verify traffic doesn't go to a degraded instance.

Key takeaways

  • Separate liveness (is the process running?) from readiness (can it do work?). Use different endpoints: /live and /ready.
  • Liveness checks should be cheap and immediate; readiness checks verify connectivity to critical dependencies only.
  • Use health check tags to organize checks by purpose, keeping logic clean and Kubernetes configuration straightforward.
  • Include only dependencies your application cannot function without. Exclude external services, analytics, and optional integrations.
  • Set aggressive timeouts on health check connections—1 to 2 seconds—so slow responses fail fast.
  • Test your health check behavior in staging by simulating dependency failures; don't assume they work until you've verified the load balancer responds correctly.
  • A 200 response from an endpoint is noise without context. Health checks that mean something tell you whether traffic should flow to that instance right now.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles