Health Checks
By the end of this lesson
Expose readiness and liveness so infrastructure can act on them.
A health check is an endpoint your infrastructure calls to ask your application how it is. It exists for machines, not people. Whatever it returns, something automatic happens: a load balancer stops sending traffic, an orchestrator restarts a container, a deployment stops rolling forward.
That is why the design matters more than it appears. A health check is not a diagnostic page. It is an input to an automated decision, and a check that answers the wrong question causes the automation to do the wrong thing.
There are two questions, and conflating them is the mistake this lesson exists to prevent.
Readiness is what stops traffic reaching a broken instance. Suppose one instance loses its database connection pool while the other three are fine. Its readiness check queries the database, fails, and the load balancer removes it from rotation. Users keep being served by the healthy three. When the pool recovers, the check passes and the instance quietly comes back.
Without a readiness check that touches the database, the load balancer sees a process responding to requests and keeps sending traffic to it. A quarter of requests fail, and from the outside the service looks intermittently broken with no obvious cause.
Liveness is for the case where the process is stuck: deadlocked, out of memory, unable to respond at all. Restarting it is the only available remedy, and that is exactly why it must not test anything outside the process.
Two checks, two audiences, two consequences:
| Liveness | Readiness | |
|---|---|---|
| The question | Is this process alive and able to respond at all? | Can this instance serve a real request right now? |
| Who calls it | The orchestrator or process supervisor | The load balancer, and the deployment during a rollout |
| What failing causes | The instance is killed and restarted | Traffic stops being routed to the instance; it keeps running |
| Should it test the database | No. Never | Yes. That is the point of it |
| Typical implementation | Return healthy unconditionally | Check each dependency the instance needs to do its job |
| Cost of getting it wrong | A dependency blip restarts every instance at once | Traffic reaches an instance that cannot serve it |
// Requires Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
// for AddDbContextCheck.
builder.Services.AddHealthChecks()
// Liveness: no dependencies at all. If this code runs, the process lives.
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
// Readiness: everything this instance needs to serve a request.
.AddDbContextCheck<HrDbContext>("hr-database", tags: ["ready"])
.AddCheck<PayrollApiHealthCheck>("payroll-api", tags: ["ready"]);
WebApplication app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
// Runs no registered checks beyond the liveness one.
Predicate = registration => registration.Tags.Contains("live"),
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready"),
});
app.MapControllers();- Tags are what let one registration list serve two endpoints. Each MapHealthChecks call filters by tag, so the liveness endpoint never touches a dependency.
- The liveness check returns healthy unconditionally. That looks pointless and is not: reaching the delegate means the process is running, the pipeline works and threads are available. If the process were deadlocked, the request would time out instead.
- AddDbContextCheck verifies that the context can connect. It is the cheapest meaningful readiness signal for an application whose main dependency is its database.
- A failing check maps to 503 by default and a healthy one to 200, which is what load balancers and orchestrators read. Note the middle case: a Degraded result also returns 200 unless you configure otherwise, so a degraded instance keeps receiving traffic. That is usually right, and it should be a decision rather than a surprise.
- Health endpoints describe your infrastructure. Restrict them to internal callers, or return no detail on the public one, so the shape of your dependencies is not published.
public sealed class PayrollApiHealthCheck(
IHttpClientFactory httpClientFactory) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
HttpClient client = httpClientFactory.CreateClient("payroll");
// Keep it short. A slow health check is itself a problem.
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(2));
using CancellationTokenSource linked =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
using HttpResponseMessage response = await client.GetAsync("ping", linked.Token);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded("Payroll responded but not successfully.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Payroll is unreachable.", ex);
}
}
}- IHealthCheck has one method. Return Healthy, Degraded or Unhealthy, and optionally a description and the exception.
- The timeout is deliberate. A health check that takes ten seconds will be interpreted as a failure by whatever called it, so bound every external call inside a check.
- Degraded is the honest answer when a dependency responds but not well. Remember that Degraded still returns 200 by default, so use Unhealthy when you want traffic to stop.
- The catch is broad on purpose. Anything at all going wrong here means the dependency cannot be relied on, and a health check that throws is less useful than one that reports.
- Be careful what goes in the description. It appears in the response, and naming hosts or connection details there publishes them.
Practical rules that follow from the above:
- Two endpoints, always: one for liveness, one for readiness
- Liveness touches nothing outside the process
- Readiness checks exactly the dependencies needed to serve a normal request, and no more
- Every external call inside a check has a short timeout
- Unhealthy means stop sending traffic; Degraded returns 200 by default, so use it when you mean traffic should continue
- Health endpoints are internal, or return no detail about your dependencies
- You have deliberately broken a dependency and watched what the automation did
Summary
- A health check is an input to automation, so its design decides what the automation does during an incident
- Liveness asks whether the process can respond, and failing it causes a restart
- Readiness asks whether this instance can serve a request, and a readiness check that tests the database is what stops traffic reaching a broken instance
- A liveness check must not test dependencies, or one shared blip restarts the entire fleet
- Keep checks fast and internal, and verify them by breaking a dependency on purpose
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Which check, which consequence
For each fault, decide whether liveness, readiness, both or neither should fail: the process has deadlocked; the database is failing over for twenty seconds; a background queue consumer has stopped but HTTP requests are served fine; the instance is still loading configuration at startup.
Show solution
Deadlock: liveness fails, because the request cannot be answered at all. A restart is the correct remedy and the only one available.
Database failover: readiness fails, liveness passes. Traffic pauses, the instance stays up, and it returns on its own when the database does. If liveness failed here, every instance would restart at once.
Stopped queue consumer: readiness should fail only if this instance is still expected to serve HTTP. If its whole purpose is consuming the queue, then a failed readiness check is right; if HTTP is its main job, a failing readiness check would remove a perfectly usable instance. This one genuinely depends on the role, and the honest answer is to decide per service rather than by rule.
Still starting up: readiness fails until initialisation completes, which is exactly what stops a rollout sending traffic to an instance that is not ready. Liveness should pass, or the instance will be killed before it can finish starting.
The pattern across all four: choose the check whose consequence would actually help.
Try it yourself
Break the database on purpose
Run an API with both endpoints against a local database. Confirm both return 200. Then stop the database and call each endpoint again.
Readiness should return 503 and liveness should still return 200. Restart the database and confirm readiness recovers with no intervention.
Show solution
This is the test that proves the separation is real. If liveness also returns 503, the liveness endpoint is running a dependency check — often because a tag was mistyped, or because a check was registered without tags and therefore matches nothing you expected.
Automatic recovery is the other half worth seeing. Readiness is not a latch; it reports the current state each time it is called, so the instance rejoins rotation without anyone doing anything.
Do this against a production-like configuration rather than only in development. Tags, timeouts and the checks that are registered often differ by environment, which means the behaviour you verified locally may not be the behaviour you deployed.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.