Skip to main content

How to Add Liveness and Readiness Health Checks to a .NET C# Web API

· 9 min read
Jagdish Kumawat
Founder @ Dewiride

A health check is a small endpoint that answers one question: is this API working? Hosting platforms, load balancers and monitoring tools call it every few seconds and act on the answer. This guide adds two of them to a .NET 10 C# minimal API, one that only checks that the API is running and one that also checks the database, and shows their real answers when the database goes down.

Liveness and Readiness​

Microsoft's health checks documentation describes two kinds of answer an app can give:

  • Liveness "indicates if an app has crashed and must be restarted."
  • Readiness "indicates if the app is running normally but isn't ready to receive requests."

The difference matters when something the API depends on fails. If the database is down, restarting the API doesn't help, so liveness should still pass. Readiness should fail, so traffic goes elsewhere until the database is back. That's why this guide builds both:

EndpointChecksFails when
/health/liveNothing beyond "the API answers"The API process is stuck or gone
/health/readyThe database connectionThe API can't reach its database

Prerequisites​

  • An ASP.NET Core minimal API on .NET 10. The examples use the inventory API from How to Connect a .NET C# Web API to a MySQL Database — Part 1, which runs on http://localhost:5190.
  • An EF Core DbContext registered with AddDbContext, for the database check. That part creates InventoryDbContext for MySQL; any EF Core provider works the same way.
  • curl, built into macOS, Linux, and Windows 10 and 11.

Step 1: Add the Database Check Package​

The health check services are part of ASP.NET Core. The EF Core database check comes from a Microsoft package:

Terminal
dotnet package add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore --version 10.0.14 --project src/Dewiride.Inventory.Api

Use the version that matches your EF Core version; this project uses EF Core 10.0.14.

Step 2: Register the Checks​

In Program.cs, before builder.Build(), register the health check services and the database check:

src/Dewiride.Inventory.Api/Program.cs (services)
// The "mysql" check opens a connection to MySQL. Only the readiness endpoint runs it.
builder.Services.AddHealthChecks()
.AddDbContextCheck<InventoryDbContext>(name: "mysql", tags: ["ready"]);
  • AddHealthChecks() adds the health check services.
  • AddDbContextCheck<InventoryDbContext> adds a check that, in Microsoft's words, "calls EF Core's CanConnectAsync method": it asks the database provider whether it can connect, using the same connection string and settings as the rest of the app.
  • name: "mysql" names the check in log messages. Without it, the name is the class name, InventoryDbContext.
  • tags: ["ready"] labels the check, so an endpoint can choose to run only the checks with that label.

Step 3: Map the Two Endpoints​

After builder.Build(), map one endpoint for each kind of check:

src/Dewiride.Inventory.Api/Program.cs (endpoints)
// Liveness: the API process is running. It runs no checks.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
});

// Readiness: the API can reach MySQL, so it's ready for requests.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});

MapHealthChecks creates an endpoint that runs checks and answers with the result. Predicate chooses which checks run: _ => false runs none, so /health/live answers as long as the API can answer at all, and check.Tags.Contains("ready") runs only the checks tagged ready. HealthCheckOptions needs using Microsoft.AspNetCore.Diagnostics.HealthChecks; at the top of the file.

The complete Program.cs:

src/Dewiride.Inventory.Api/Program.cs
using Dewiride.Inventory.Data;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

// The MySQL database, through EF Core. The connection string comes from configuration:
// dotnet user-secrets in development, an environment variable everywhere else.
builder.Services.AddInventoryData(builder.Configuration);

// The "mysql" check opens a connection to MySQL. Only the readiness endpoint runs it.
builder.Services.AddHealthChecks()
.AddDbContextCheck<InventoryDbContext>(name: "mysql", tags: ["ready"]);

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}

// Liveness: the API process is running. It runs no checks.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
});

// Readiness: the API can reach MySQL, so it's ready for requests.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});

app.Run();

The Program.cs file in a macOS-style code window, registering AddHealthChecks with an AddDbContextCheck named mysql and tagged ready, and mapping /health/live with a predicate that runs no checks and /health/ready with one that runs the ready checks.

AddInventoryData is the method that registers the DbContext; the MySQL series explains it.

Step 4: Call Both Endpoints​

Start the API with dotnet run --project src/Dewiride.Inventory.Api, and call each endpoint from a second terminal:

Terminal
curl -i http://localhost:5190/health/live
curl -i http://localhost:5190/health/ready

Both answer the same way, apart from the time. This is /health/ready:

Output
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Mon, 02 Nov 2026 16:25:13 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: Healthy, Degraded or Unhealthy.
  • The status code follows the result. By default, Healthy and Degraded answer 200 OK, and Unhealthy answers 503 Service Unavailable, so a tool that only looks at the status code still gets it right.
  • The three no-cache headers come from the middleware, which by default sets them "to prevent response caching", so a proxy never serves an old answer.

Step 5: Take the Database Away​

This is the test that matters. With the API running, stop the database. Here it's MySQL in Docker:

Terminal
docker compose stop

Then call both endpoints again. Liveness still passes:

Output (/health/live)
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Mon, 02 Nov 2026 16:44:55 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

Readiness fails:

Output (/health/ready)
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain
Date: Mon, 02 Nov 2026 16:44:56 GMT
Server: Kestrel
Cache-Control: no-store, no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Transfer-Encoding: chunked

Unhealthy

A terminal where /health/live answers 200 OK Healthy and /health/ready answers 503 Service Unavailable Unhealthy while the MySQL container is stopped.

And the API logs which check failed:

Output (API terminal)
fail: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103]
Health check mysql with status Unhealthy completed after 21.9302ms with message '(null)'

mysql is the name from Step 2. The message is (null) because CanConnectAsync only answers yes or no. Start the database again (docker compose start --wait), and the next readiness call answers 200 OK with Healthy, without restarting the API.

Make It Safe to Expose​

Health endpoints answer anyone who can reach the API. The plain Healthy text reveals almost nothing, which is one reason to keep the default output. If you customize it, Microsoft's documentation shows how to:

  • Write JSON with per-check details using HealthCheckOptions.ResponseWriter. Details such as error messages belong on an internal endpoint, not a public one.
  • Answer only on one port with .RequireHost("*:5001"), so the endpoint can be reachable from the monitoring network only.
  • Require authentication with .RequireAuthorization().

Common Mistakes​

MistakeWhat happensDo this instead
Checking the database in the liveness endpointA database outage makes the platform restart a healthy API, over and overKeep liveness free of dependencies
Forgetting Predicate on /health/readyIt runs every check, which is fine until you add checks that shouldn't block trafficTag the readiness checks and filter on the tag
Running a heavy query in a custom checkEvery probe, every few seconds, loads the databaseMicrosoft says connecting is usually enough; if you must query, use something like SELECT 1
Leaving out the EF Core packageAddDbContextCheck doesn't existAdd Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

Troubleshooting​

SymptomLikely causeFix
/health/ready always answers 503The database is down, the connection string is wrong, or the database doesn't existCheck the database, then the connection string
/health/ready answers 200 with the database stoppedThe readiness endpoint runs no checksMake sure the check has the ready tag and the predicate filters on it
404 Not FoundThe path doesn't match MapHealthChecksCompare the URL with the mapped path

FAQ​

How often do health checks run?​

Only when the endpoint is called. Microsoft's documentation says they "don't run on a timer by default"; each request runs the checks again.

What does the EF Core check send to the database?​

It calls CanConnectAsync, and what that sends depends on the provider. With Oracle's MySQL provider, it connects and runs SHOW DATABASES LIKE 'inventory'; the MySQL series shows that from inside MySQL.

Do I need both endpoints?​

For a platform that restarts unhealthy apps, yes. With only one endpoint that checks the database, a database outage would make the platform restart a perfectly healthy API.

Conclusion​

Two calls set up both checks: AddHealthChecks().AddDbContextCheck<T>() registers a tagged database check, and two MapHealthChecks calls with predicates turn it into /health/live and /health/ready. When the database stopped, liveness kept answering 200 and readiness answered 503, which is exactly what a hosting platform needs to make the right decision.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.