← Open in the full interactive course (progress tracking, search & more)

Nobody is watching a dashboard at 3am — the orchestrator has to know your app is broken before a human ever would.

Your API runs as ten instances behind a load balancer. One of them hits a bug and its request-handling thread gets stuck — the process is technically still running, but it will never successfully answer another request again. Nobody is staring at a dashboard at 3am to notice. Meanwhile, a completely different instance just started up and is still opening its database connection — for the next two seconds it's alive, but genuinely not ready to do useful work yet. If the load balancer keeps sending traffic to either of these instances, real users get real failures, for a problem the system itself had every ability to detect and route around automatically.

Health checks exist to give infrastructure — load balancers, container orchestrators like Kubernetes — an automated, reliable answer to "is this specific instance okay to send traffic to right now?", without a human in the loop at all.

In this lesson, you'll learn ASP.NET Core's built-in health checks middleware, the crucial distinction between liveness and readiness checks, and how to write and register a custom health check.

What Is It?

The Simple Explanation

A health check is an HTTP endpoint your app exposes that answers one simple question: "are you okay?" Infrastructure automatically pings this endpoint on a regular interval and reacts based on the answer — without ever needing a human to look at anything.

The Technical Definition

ASP.NET Core's health checks middleware, part of Microsoft.Extensions.Diagnostics.HealthChecks, is registered via builder.Services.AddHealthChecks() and exposed as one or more HTTP endpoints via app.MapHealthChecks("/health"). Each registered health check is a small, discrete unit that reports one of three statuses — Healthy, Degraded, or Unhealthy — and the middleware aggregates the results of all registered checks into a single overall response.

Why Does It Exist?

The Problem — Automated Systems Can't Just "Look" at Your App

A load balancer distributing traffic across ten instances, or a container orchestrator like Kubernetes deciding whether to restart a pod, has no way to know your app's internal state unless your app tells it, in a format the infrastructure understands. "The process is still running" is not the same thing as "this instance can correctly serve a request right now" — a process can be alive and completely useless (deadlocked, out of database connections, mid-startup) at the same time. Without a standard, automated signal, unhealthy instances keep receiving traffic until a human notices something is wrong — usually via a spike in error rates or an angry customer.

The Solution — A Standard, Machine-Checkable Signal

Health checks give infrastructure a simple HTTP contract to poll on a schedule: hit an endpoint, look at the status code (and optionally the response body), and act automatically — pull the instance out of the load-balancing pool, restart it, or leave it alone. This turns "detect and react to a broken instance" from a manual, reactive, human process into an automated, proactive one.

Big Picture

LIVENESS vs. READINESS — TWO DIFFERENT QUESTIONS, TWO DIFFERENT RESPONSES
LIVENESS — "Is this process still alive and functioning?"
READINESS — "Can this specific instance properly serve traffic right now?"

The distinction is genuinely important, not a naming technicality: conflating the two means an instance that's merely warming up (a readiness problem) gets needlessly restarted (the liveness response) — or worse, an instance that's genuinely hung (a liveness problem) just keeps getting quietly skipped for traffic forever instead of ever being restarted.

How It Works

FROM REGISTRATION TO AN AUTOMATED RESPONSE
1. REGISTER HEALTH CHECKS AT STARTUP
builder.Services.AddHealthChecks()
    .AddCheck<DatabaseHealthCheck>("database", tags: ["ready"]);
2. MAP ONE OR MORE HEALTH CHECK ENDPOINTS
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => !check.Tags.Contains("ready") // liveness ignores readiness-only checks
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});
3. THE ORCHESTRATOR POLLS AND REACTS

Simple Example

The absolute minimum health check setup — a single endpoint reporting overall app health with no custom logic:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();

var app = builder.Build();
app.MapHealthChecks("/health");

app.Run();

A request to GET /health now returns 200 OK with the text Healthy as long as the app is running and able to respond — a useful starting point, but it doesn't yet check anything meaningful about the app's actual ability to do its job (like whether the database is reachable). That's what a custom health check is for.

Real-World Example

A custom health check that verifies the app can actually reach its database — a genuine readiness concern, since a database connection issue means the instance can't correctly serve most real requests, even though the process itself is running fine:

public class DatabaseHealthCheck(AppDbContext db) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            // A cheap, fast query — just prove connectivity, don't do real work here
            var canConnect = await db.Database.CanConnectAsync(cancellationToken);

            return canConnect
                ? HealthCheckResult.Healthy("Database connection is healthy")
                : HealthCheckResult.Unhealthy("Cannot connect to the database");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Database check threw an exception", ex);
        }
    }
}

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck<DatabaseHealthCheck>("database", tags: ["ready"]);

var app = builder.Build();

// Liveness: is the process itself alive? No dependency checks — a slow DB shouldn't trigger a restart.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // no registered checks run here; a 200 just means the app responded at all
});

// Readiness: can this instance actually serve traffic right now?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

Notice the deliberate design: the liveness endpoint runs no dependency checks at all — a temporarily slow or unreachable database shouldn't cause Kubernetes to restart a perfectly healthy process, because restarting won't fix a database problem and would just cause unnecessary churn. The readiness endpoint is where dependency health belongs, because that's precisely the signal that should pull the instance out of traffic without restarting it.

Analogy

A Restaurant Kitchen

Liveness is asking "is the kitchen on fire, or has the chef collapsed?" — a genuine emergency that means you shut the kitchen down and bring in a replacement (restart). Readiness is asking "is the kitchen currently able to plate a dish right now?" — maybe it's mid-prep, maybe a specific ingredient delivery hasn't arrived yet. Neither of those means the kitchen is broken; it just means don't seat a table expecting food from it this exact minute. You'd stop sending customers there for a few minutes (readiness failure — pull from rotation), not tear the whole kitchen down and rebuild it (liveness failure — restart) over a late ingredient delivery.

Under the Hood

WHAT HAPPENS WHEN THE ORCHESTRATOR HITS /health
1. THE MIDDLEWARE RUNS EVERY MATCHING REGISTERED CHECK
2. RESULTS ARE AGGREGATED INTO ONE OVERALL STATUS
3. THE RESULT MAPS TO AN HTTP STATUS CODE THE ORCHESTRATOR UNDERSTANDS

Common Confusion

"One /health endpoint checking everything is good enough" — often not, and can actively hurt you

A single endpoint that runs every check, including downstream dependency checks, and is used for both liveness and readiness purposes conflates two genuinely different failure responses. If your database is briefly unreachable and your liveness probe includes a database check, the orchestrator may restart every single instance simultaneously — none of which fixes the database problem, and which now adds a total outage (everything restarting at once) on top of the original issue. This is precisely why liveness and readiness are usually split into separate endpoints with deliberately different sets of underlying checks.

"Unhealthy always means restart" — it depends entirely on which probe reported it

The word "unhealthy" doesn't itself imply a specific remedy — the remedy is determined by which kind of probe (liveness vs. readiness) reported it, because each carries a different, deliberately configured orchestrator response.

Common Mistakes

Mistake 1 — Putting downstream dependency checks on the liveness endpoint

Including a database or external API check as part of what determines liveness — a temporary dependency outage now looks identical to the process itself being broken, and triggers unnecessary restarts that don't fix anything.

Keep liveness checks limited to "is this process itself responsive" — put dependency checks on the readiness endpoint instead.

Mistake 2 — Health checks that do real, expensive work

A health check that runs a heavyweight query, or exercises real business logic, just to answer "am I healthy?" — this adds load exactly where you don't want it, and can itself become a bottleneck under frequent polling.

Keep checks cheap and fast — a lightweight connectivity check (CanConnectAsync) rather than a real query against production data.

Mistake 3 — No health checks at all, relying purely on manual monitoring

Deploying to an orchestrated environment without any health check endpoints — the orchestrator falls back to much cruder signals (like "did the process crash entirely"), missing the far more common case of a process that's alive but stuck or not yet ready.

At minimum, expose a basic liveness endpoint; add readiness checks for real dependencies as your app's needs grow.

When Should I Use It?

Mental Model

Liveness = "Are you still alive?" → No → restart me
Readiness = "Can you serve traffic right now?" → No → skip me for now, don't restart

Remember: a process can be perfectly alive and still not ready — those are two different questions, and conflating them causes exactly the wrong automated response.

Key Takeaway


Check Your Understanding

You've seen why liveness and readiness are genuinely different questions. Let's confirm you can apply the distinction correctly.

1. An instance is running fine but its database connection pool is briefly exhausted, so it can't currently serve most requests successfully. What is the correct classification and orchestrator response?

Show answer

Correct: B

Why B is correct: The process itself is fine — it's a temporary inability to serve traffic due to a dependency issue, which is exactly what readiness checks are for. Removing it from rotation without restarting avoids unnecessary churn while the underlying condition (connection pool pressure) potentially resolves on its own.

Why A is incorrect: Restarting doesn't fix a connection pool exhaustion problem and treats a readiness issue as if the process itself were broken — exactly the conflation the lesson warns against.

Why C is incorrect: A custom IHealthCheck (like a database connectivity check) can absolutely detect this — that's a standard use case shown in the lesson.

Why D is incorrect: This isn't a liveness concern at all, and ignoring a readiness signal defeats the purpose of having one — the instance should be pulled from traffic, not ignored.

Reinforcement: Dependency-related failures belong on the readiness check, which triggers removal from rotation, not the liveness check, which triggers a restart.

2. Why is it a mistake to include a downstream database check as part of the liveness endpoint?

Show answer

Correct: B

Why B is correct: This is the exact scenario the lesson warns about — restarting instances doesn't repair an external dependency issue, and a synchronized mass-restart across every instance could itself cause an outage on top of the original database problem.

Why A is incorrect: Liveness endpoints can technically run any registered check via the Predicate — the mistake is a design choice, not a technical limitation.

Why C is incorrect: A check's returned status (Healthy/Degraded/Unhealthy) is up to its implementation, not dictated by which endpoint it's attached to.

Why D is incorrect: This directly contradicts the lesson's core point — liveness and readiness deliberately answer different questions and should generally run different sets of checks.

Reinforcement: Keep liveness narrow (is the process itself alive) and put dependency checks on readiness, where the response (pull from rotation) actually fits the failure.

3. What is the primary reason health checks should be kept cheap and fast, rather than running real, expensive business logic?

Show answer

Correct: B

Why B is correct: Since orchestrators poll these endpoints on a regular schedule (often every few seconds), an expensive check runs that expensive work repeatedly and continuously — adding real, recurring load to the exact system you're trying to keep healthy, purely for a status check.

Why A is incorrect: There's no such hardcoded limit in the framework — the recommendation is a design guideline, not an enforced constraint.

Why C is incorrect: A check's cost doesn't determine its returned status — an expensive check can still correctly report Healthy.

Why D is incorrect: IHealthCheck.CheckHealthAsync is fully async and expected to be used with await for real I/O like database calls.

Reinforcement: A health check's job is to answer quickly and cheaply — a lightweight connectivity check, not a full exercise of business logic.

You now understand how health checks let infrastructure automatically detect and respond to instance-level problems — and why liveness and readiness demand genuinely different responses.


dotnetmadeeasy.com — Learn C# and .NET, the right way.