An agent calls your API, gets a timeout, and has to decide whether to retry, wait, or give up.
It polls /health, which answers 200 because the process is running. The database behind it
has been down for ten minutes. Nothing the agent can read says why every attempt fails, so it
retries into the outage.
What you get
You will end up with a health endpoint that reports a status, a release id, and one line per dependency. A test takes a dependency down and watches it go red. This is for you if agents poll your API before calling it.
Short answer
Serve GET /health with a body in the application/health+json shape: an overall status of
pass, warn, or fail, a version, a releaseId, and a checks object with one entry per
dependency. Run every check on every request under a deadline, return 503 when any check fails,
and send Cache-Control: max-age=5 so a crowd of polling clients costs one check per five
seconds.
You will need
Node 22 or later, and a deployed API or docs site to put the route on. Verified 2026-09-24 against Node 22.22.2. The body shape follows the IETF health check draft, an Internet-Draft that expired without becoming an RFC,1 so its field names are a convention with a written description rather than a standard. That is still better than a convention with none.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hosted status page such as Atlassian Statuspage | People need history, incident text, and subscriptions | Somebody has to post the update, so the page lags the outage by however long that takes | The reader is a program deciding whether to retry in the next second |
| A plain JSON health route | A restart probe, where the only question is whether the process is alive | Says nothing about dependencies, so it stays green while the database is down | Anything that decides whether to call the API rather than whether to restart it |
| application/health+json from the IETF draft | Agents and monitors that want a per-dependency answer in one request | A draft rather than a standard, and every check runs on every poll unless you cache | The API has no dependencies worth reporting, or the clients read only the status code |
The plain route and the status page fail in opposite directions. The route is automatic and
says nothing, so it stays green through an outage the database has been having for an hour.
The status page says a great deal and is typed by a person, so it turns red after somebody
notices. The health+json body sits between them: automatic, and specific about which
dependency failed.
A status page still earns its place beside the endpoint. An agent reads the endpoint to decide what to do in the next second. A person reads the status page to find out what happened last Tuesday, and the endpoint has no memory of that.
Run every check under a deadline
A health check that calls the database inherits the database’s failure modes, including the one where it never answers. The endpoint has to answer anyway.
async function runCheck(check, timeoutMs) {
let timer
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)
})
const started = new Date().toISOString()
try {
const { status = 'pass', ...result } = (await Promise.race([check.run(), deadline])) ?? {}
return { componentType: check.componentType, status: canonical(status), time: started, ...result }
} catch (err) {
return { componentType: check.componentType, status: 'fail', time: started, output: err.message }
} finally {
clearTimeout(timer)
}
}
A check that throws becomes a fail entry with the error text in output. A check that hangs
loses the race and becomes a fail entry that says so. Neither reaches the request handler as
an exception, which is the property that keeps the endpoint up when a dependency is down. The
draft asks for output to be omitted on pass, and the spread on the success path does that by
never setting it. The status a check answers with goes through canonical, a seven-entry table:
the draft accepts ok and up for pass and error and down for fail, and a status the
table does not know ranks as fail. Without that, a check answering down reads as a pass,
and the endpoint stays green.
Say which dependency failed
The overall status is the worst status of any check, and the checks are keyed by dependency and
measurement, which the draft writes as component:measurement.
export async function healthReport({ version, releaseId, checks, timeoutMs = 2000 }) {
const results = await Promise.all(checks.map((c) => runCheck(c, timeoutMs)))
const body = { status: 'pass', version, releaseId, checks: {} }
for (const [i, check] of checks.entries()) {
body.checks[check.name] = [results[i]]
if (RANK[results[i].status] > RANK[body.status]) body.status = results[i].status
}
return body
}
The checks run concurrently. Four dependencies with a two second deadline each answer in two seconds at worst, not eight.
version and releaseId are two fields because they move at different speeds. The draft
separates the public API version, which changes rarely, from the release that is running, which
changes on every deploy. An agent that sees a 500 at releaseId 2026.09.24-4f1c9e2 and a 200
at the next one has something to put in its bug report.
Each key holds an array, even for one node, so a dependency with three replicas can report three
entries under one key without changing the shape. The componentType values here,
datastore and component, are the ones the draft lists.
Cache for seconds, not minutes
Twenty agents polling every second is twenty database pings a second. A max-age of five
seconds turns that into one, and delays the news of an outage by at most five seconds.
export function healthHandler(options, { maxAgeSeconds = 5 } = {}) {
return async (req, res) => {
const body = await healthReport(options)
const status = httpStatus(body.status)
const headers = {
'content-type': 'application/health+json',
'cache-control': `max-age=${maxAgeSeconds}`,
}
if (status === 503) headers['retry-after'] = String(maxAgeSeconds)
res.writeHead(status, headers)
res.end(JSON.stringify(body))
}
}
The HTTP status carries the verdict for clients that never parse the body. The draft requires
2xx for pass and warn, and 4xx or 5xx for fail.
RFC 9110 defines 503 as a server
that cannot handle the request for the moment, with Retry-After as the hint for when to try
again. Sending the same number in Retry-After and max-age means a client that honors either
header comes back at the same time.
max-age is defined in
RFC 9111, and a 503 is only
cached because the header says so explicitly. The draft’s own example uses max-age=3600,2
which is an hour of not noticing. Keep it to seconds.
Check it worked
Start the server, poll it, take the database down, and poll it again. Then make the queue check hang.
node demo.mjs
everything up
HTTP 200 application/health+json cache-control: max-age=5
status pass version 1 releaseId 2026.09.24-4f1c9e2
database:connections pass 3 connections
queue:depth pass 12 messages
database down
HTTP 503 application/health+json cache-control: max-age=5
status fail version 1 releaseId 2026.09.24-4f1c9e2
database:connections fail connect ECONNREFUSED 10.0.0.12:5432
queue:depth pass 12 messages
queue check hangs
HTTP 503 application/health+json cache-control: max-age=5
status fail version 1 releaseId 2026.09.24-4f1c9e2
database:connections pass 3 connections
queue:depth fail timed out after 300ms
The second block is the one that matters. The status is 503, the body says fail, and the
line for the database carries the error the connection attempt raised. The queue line still says
pass, so a reader can see that one dependency is down and not the whole service.
The third block is the deadline doing its job. The queue check never returns, the endpoint answers in 300 milliseconds anyway, and the output names the timeout.
The tests pin the same three behaviors, plus a check that throws, a warn that keeps its 200,
a check that answers with one of the draft’s aliases, and the cache header.
node --test health.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 355.551534
When it goes wrong
The endpoint is green and the API is down. The route returns 200 whenever the process can run a handler, without asking a dependency anything. Add the checks. Here is the difference with the database switched off, on one server with both routes.
node pitfall.mjs
/healthz HTTP 200 says ok
/health HTTP 503 says fail
database: down
The /healthz route is not wrong for the question a restart probe asks, which is whether the
process is alive. It is wrong for an agent deciding whether to retry, and a route that
answers both questions with the same 200 teaches agents to keep retrying against a database that
is down.
The endpoint times out. One check has no deadline of its own and the dependency it calls has
stopped answering. Every check goes through runCheck, which imposes one, so a check with no
deadline is a check that bypassed the function.
The endpoint stays red after the outage ends. The cache is too long, or a proxy in front of the
service added its own. Check the cache-control the client received, not the one you sent.
The polling clients take the service down. A health check that runs an expensive query is a query every agent can trigger for free. Keep each check to a ping or a cheap read, and let the cache absorb the rest.
When not to do this
Do not report fail for a dependency the request path can live without. A search index that is
rebuilding takes down nothing an agent needs to create a record, and an endpoint that says
fail for it sends every agent away. Report it as warn, which keeps the 200, and put the
detail in the checks.
Do not put secrets or internal host names in output. The error text from a database driver
often includes the host and port, as the demo shows, and the endpoint is public by design. Strip the message before it goes out, or keep the detailed body behind authentication
and serve an unauthenticated one with the status alone.
Do not replace the status page with this. A person who wants to know what happened yesterday
gets nothing from an endpoint that reports the last five seconds, and a status page with
incident text is the record. Run both, and link the page from the endpoint’s links field.
Do not run the checks on a timer and serve the cached result if the cache is your only defense
against polling clients. The max-age header already does that job in every HTTP client and proxy on
the path, and a second cache inside the process is a second thing that can go stale.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it
printed. The dependencies are stubs in server.mjs with a switch the demo flips, not a
database and a queue.
Footnotes
-
The draft is
draft-inadarei-api-health-check-06, which the IETF Datatracker files as expired, its sixth revision having lapsed in April 2022. It acceptsokandupas aliases forpass, anderroranddownforfail, and says in so many words that the aliases exist to accommodate Node’s Terminus and Java’s Spring Boot. A specification that names the two libraries it is bending to is rarer than one that bends without saying. ↩︎ Back to text -
The example response in section 5 of the draft carries
Cache-Control: max-age=3600, so the document that defines a health check illustrates it with one that may be an hour stale. Nothing in the text explains the number. The example is the part people copy. ↩︎ Back to text