# How to add a health endpoint agents can poll

> Return a status, a release id, and one line per dependency, cache it for seconds, and prove the endpoint goes red when a dependency goes down.

Source: https://voxgig.com/howto/add-a-health-endpoint-for-agents

- Audience: platform-team
- Level: beginner
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check-06),
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](https://developer.statuspage.io/) | 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](https://kubernetes.io/docs/concepts/workloads/pods/probes/) | 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](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check-06) | 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.

```ts title="health.mjs"
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`.

```ts title="health.mjs"
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.

```ts title="health.mjs"
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](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.4) 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](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.1), 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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test health.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
/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

- [Set robots.txt rules for each AI crawler](/howto/set-robots-rules-for-ai-crawlers)

- [Publish security.txt for an API](/howto/publish-security-txt-for-an-api)

## 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.

[^1]: The draft is
[`draft-inadarei-api-health-check-06`](https://datatracker.ietf.org/doc/draft-inadarei-api-health-check/),
which the IETF Datatracker files as expired, its sixth revision having lapsed in April 2022.
It accepts `ok` and `up` as aliases for `pass`, and `error` and `down` for `fail`, 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.

[^2]: The example response in [section 5 of the
draft](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check-06) 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.