Blogs

Health checks lie in very specific ways

A green endpoint can mean the process is alive, not that the product works.

Most backend apps eventually get a /health route.

And a lot of them start like this:

func health(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

I have written this too, and it is fine for a pulse check.

But it answers a very small question: can this process receive a request and write a response?

That is useful, but the name promises more than the check proves. A process can be alive while login is broken, payments are failing, Redis is down, or the app is serving old data from cache.

So the lie usually starts with the name. /health sounds bigger than what it checks.

alive and ready are different

Kubernetes has separate liveness and readiness probes, and that split is a good mental model even if you are not using Kubernetes.

Liveness means:

Should this process be restarted?

Readiness means:

Should this process receive traffic?

Those are different questions.

If the database is down for a few seconds, the app may not be ready. But restarting every web process will probably not fix the database. It can even make things worse because now all the processes are restarting while the dependency is already sad.

I used to think health checks should check everything. Now I think they should check the right thing for the job they are wired to.

”select 1” is also a small promise

Checking the database with this:

SELECT 1;

is better than checking nothing.

But it only proves that the database accepted a tiny query. The important query might still fail, migrations might still be wrong, or the connection pool might still run out under traffic.

It helps, within a narrow promise.

This is where I think teams accidentally fool themselves. The dashboard is green, so the brain relaxes, even though the check never asked the product question.

what I like doing

I prefer splitting checks by purpose.

/live should be boring and local. If it fails, restarting the process might help.

/ready can check the things needed to serve normal traffic. Database reachable, config loaded, migrations compatible, stuff like that.

/smoke can test one real user-ish path, but I would not run it every second from every region. It is more for deploy verification or synthetic checks.

Something like this feels easier to reason about:

/live   -> process is alive
/ready  -> instance can receive traffic
/smoke  -> one important path works

None of these are perfect. But each one lies less broadly.

say what failed

This output gives too little:

{ "ok": false }

I would rather see this internally:

{
  "status": "not_ready",
  "checks": {
    "database": "ok",
    "redis": "timeout",
    "migrations": "ok"
  }
}

Public output can stay boring. Internal output should help the person debugging it.

The small security note is obvious but still worth saying: keep hostnames, secrets, versions, and stack traces away from public health routes. I have seen enough messy internal endpoints to be scared of them lol.

my rule

I like naming checks after the promise they make.

If a route only proves the process is alive, call it live. If it proves the instance can take traffic, call it ready. If it checks a real workflow, call it smoke or something similar.

The code can still be simple. The name just needs to be honest.

That one change prevents a lot of confusion later.