How to test a health check endpoint

Send one GET and read the status code, not the body: curl -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}s\n' http://127.0.0.1:8763/healthz printed code=200 time=0.008486s. Then break a dependency and repeat the request. A health endpoint worth having answers 503 within the same second, and a probe reading only exit codes cannot tell those two apart.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

A health endpoint is the contract between your service and everything that decides whether to send it traffic: a load balancer pool, a container orchestrator, a deployment gate, an uptime monitor. Test it on staging sign-off and again after any change to the handler, because nothing else in the system fails loudly when it is wrong.

The failure it prevents is a pool that never drains. A service whose database is gone keeps answering 200 on /healthz, the load balancer keeps routing to it, and every request it receives returns an error to a customer. The health endpoint was the one component that could have removed the instance, and it reported success.

Prerequisites

Save this as health-server.js. It answers /healthz and carries a control route that flips the reported health, so both answers come from one process.

// node health-server.js   listens on http://127.0.0.1:8763
const http = require('http');
const state = { healthy: true };
const send = (res, code, body) => {
  res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
  res.end(JSON.stringify(body));
};
http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  if (url.pathname === '/control') {              // test hook, never shipped
    state.healthy = url.searchParams.get('healthy') !== 'false';
    return send(res, 200, state);
  }
  if (url.pathname === '/healthz') {
    return state.healthy
      ? send(res, 200, { status: 'ok', version: '1.4.2' })
      : send(res, 503, { status: 'unhealthy', reason: 'order-db unreachable' });
  }
  send(res, 404, { status: 'not found' });
}).listen(8763, '127.0.0.1', () => console.log('health fixture on 127.0.0.1:8763'));

Start it, and read the Windows PID out of netstat for the last step.

node health-server.js & netstat -ano | grep 8763

Steps

  1. Step 1.

    Send one request and read the three numbers that matter, without the body in the way.

    curl -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}s size=%{size_download}\n' http://127.0.0.1:8763/healthz
    
    code=200 time=0.008486s size=33

    A 33 byte body answered in 8 ms. Latency on a loopback address has no network in it, so treat this number as the handler's own cost and nothing more.

  2. Step 2.

    Read the headers and the body once, to record the shape the probe will be matched against.

    curl -sS -i http://127.0.0.1:8763/healthz
    
    HTTP/1.1 200 OK
    content-type: application/json
    cache-control: no-store
    Date: Fri, 11 Sep 2026 22:54:19 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    {"status":"ok","version":"1.4.2"}

    cache-control: no-store is the line to look for. Without it a proxy may answer on the service's behalf and the probe never reaches the process.

  3. Step 3.

    Break the reported health through the control route, so the next request has something to fail on.

    curl -sS "http://127.0.0.1:8763/control?healthy=false"
    
    {"healthy":false}

    In a real service this is a stopped database, a full disk or a failed migration. The control route exists so the page can produce the state on demand.

  4. Step 4.

    Repeat the request from step 2 against the unhealthy service.

    curl -sS -i http://127.0.0.1:8763/healthz
    
    HTTP/1.1 503 Service Unavailable
    content-type: application/json
    cache-control: no-store
    Date: Fri, 11 Sep 2026 22:54:27 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    {"status":"unhealthy","reason":"order-db unreachable"}

    Same URL, same handler, a different status line. That is the only part a load balancer reads.

  5. Step 5.

    Run the endpoint the way a smoke test usually does, with plain curl and a shell conjunction.

    curl -s -o /dev/null http://127.0.0.1:8763/healthz && echo "smoke test passed"
    
    smoke test passed

    The service said 503 and the test said passed. curl returns 0 for any response it managed to receive, whatever the status code was.

  6. Step 6.

    Add --fail and run the identical request.

    curl -sf -o /dev/null http://127.0.0.1:8763/healthz && echo "smoke test passed" || echo "smoke test failed, curl exit $?"
    
    smoke test failed, curl exit 22

    Exit 22 is curl's code for an HTTP response above 400 under --fail. This is the one flag that makes a health check usable in a pipeline.

  7. Step 7.

    Stop the process with the PID from netstat, then send the same request to a port with no listener.

    curl -sS -o /dev/null -w 'code=%{http_code}\n' http://127.0.0.1:8763/healthz; echo "exit=$?"
    
    curl: (7) Failed to connect to 127.0.0.1:8763 after 2026 ms: Could not connect to server
    code=000
    exit=7

    There is no status code, so %{http_code} prints 000, and the refusal took 2026 ms on this machine rather than returning at once. A check that stores http_code and alerts on 5xx sees nothing here.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | code=200 and a body naming the build | The handler ran and claims health | Record the body shape and assert on it in the test, not on the words. | | code=503 | The service removed itself from the pool | Confirm the pool drained, then find the dependency named in the body. | | code=000, curl exit 7 | Nothing is listening: the process is gone | Different fault from 503. Both are down, only one can report a reason. | | curl exit 28 | The handler accepted the connection and never answered | See Health check timeout. | | code=200 with unhealthy in the body | Status code and body disagree | See Deep health check. |

Common mistakes

Sign: A smoke test reports the service up while the endpoint answers 503.Cause: curl exits 0 for any response it received, 5xx included. Step 5 printed 'smoke test passed' against a 503. Only --fail turns the status into exit 22, and a pipeline written without it passes for as long as the process is running.
Sign: The monitor alerts on 5xx and stays quiet during a full outage.Cause: A refused connection carries no status code. curl printed code=000 and exited 7. A rule matching 500 to 599 never fires on the fault where the process is not there at all, which is the worse of the two.
Sign: The endpoint is checked once, healthy, and signed off.Cause: A health endpoint that returns 200 has proved nothing until it has also been seen returning 503. Half of the contract is the failure path, and it is the half that never runs in production until the night it matters.
Sign: The response is served from a cache.Cause: Without cache-control: no-store, a CDN or reverse proxy can answer the probe from a stored copy. The probe then measures the proxy. Step 2 shows the header on every response, including the 503.

What to check next

FAQ

What is the use of a /health endpoint?

It gives one machine-readable answer to one question: should traffic go to this instance right now. Load balancers, orchestrators and deploy gates poll it and act on the status code. Nothing else in the system offers to take an instance out of rotation on its own.

Is there a standard for health check endpoints?

No RFC defines one. The paths /healthz, /health and /status are all in common use. Treat the path, the status code rule and the body shape as a contract you write down and test, because the platform polling it will only read the status code.

How do I check a health endpoint with curl?

curl -sf -o /dev/null -w '%{http_code}' <url>. The -f makes a 5xx a non-zero exit, -o /dev/null keeps the body out of the output, and -w prints the status code that the pool decision is made on.

What should a healthz endpoint return?

200 when the instance can serve, 503 when it cannot, and a small JSON body naming the build and the failing dependency. Keep it under a second and never let it require authentication that the probe cannot supply.

Does a 200 mean the service works?

It means the handler ran and returned 200. Whether it looked at anything is a separate question, answered by reading the body and by the deep check page. Status code and body can disagree, and monitors usually read only one of them.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic8 minpublished updated Maks Verny