Health Endpoints
Build a health endpoint that says what is broken. @openstatus/health runs one probe per dependency and answers ok, degraded, or unhealthy — on Node, Bun, Deno, and edge runtimes.
A monitor that polls your homepage for a 200 tells you the process is alive. It does not tell you that your database is gone, that your cache is timing out, or that the queue you depend on stopped accepting writes — all of which happily return 200 right up until the moment they matter.
@openstatus/health is the toolkit we extracted from our own services to close that gap. You describe one probe per dependency; it runs them concurrently, caches the result, and renders a JSON report that says ok, degraded, or unhealthy — and which dependency is responsible.
Zero dependencies. Runs on Node 22+, Bun, Deno, and edge runtimes. MIT licensed.
Install
npm install @openstatus/health
Or from JSR:
deno add jsr:@openstatus/health
Quickstart
import { createHealthHandler, httpProbe, readEnv } from "@openstatus/health";
const handler = createHealthHandler({
path: "/health",
probes: [
{
name: "database",
critical: true,
run: () => db.run(sql`select 1`),
},
{
name: "redis",
skip: () => readEnv("UPSTASH_REDIS_REST_URL") == null,
run: () => redis.ping(),
},
httpProbe({ name: "unkey", url: "https://api.unkey.com/v2/liveness" }),
],
});
Deno.serve(handler);
createHealthHandler returns a plain (request: Request) => Promise<Response>, so anything that speaks the Fetch API needs no adapter — Next.js route handlers, SvelteKit, Astro, Nitro, Fresh, React Router resource routes, Bun.serve, Deno.serve:
export const GET = createHealthHandler({ probes });
For frameworks with their own request objects, there is a thin adapter:
import { healthRoute } from "@openstatus/health-hono";
app.route("/", healthRoute({ path: "/ping", probes }));
The response
{
"status": "degraded",
"checkedAt": "2026-09-11T12:00:00.000Z",
"latencyMs": 41,
"checks": [
{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 },
{ "name": "redis", "status": "skipped", "critical": false, "latencyMs": 0 },
{ "name": "unkey", "status": "timeout", "critical": false, "latencyMs": 5000, "error": "timed out after 5000ms" }
]
}
One failing dependency does not have to mean the whole service is down. Marking a probe critical is how you say the difference:
| Probe results | Report status | Default HTTP status |
|---|---|---|
every probe ok or skipped | ok | 200 |
| a non-critical probe failed or timed out | degraded | 200 |
| a critical probe failed or timed out | unhealthy | 503 |
HEAD returns the same status code with no body. Other methods get 405 with an allow: GET, HEAD header. Every response carries content-type: application/json and cache-control: no-store.
Probes
A probe is an object with a name and a run function. It is healthy when run resolves and failed when it throws:
interface Probe {
name: string;
critical?: boolean; // default false
timeoutMs?: number; // default 5000
skip?: () => boolean | Promise<boolean>;
run: (signal: AbortSignal, ctx: ProbeContext) => unknown | Promise<unknown>;
}
The signal aborts when timeoutMs elapses — pass it to fetch and other cancellable calls. A probe that ignores it and settles late is still reported as timeout; the late result is discarded and can never surface as an unhandled rejection.
skip runs first, inside the same timeout window, and reports the check as skipped without calling run. It is how you handle a dependency that is not configured in every environment, and it may be async, so feature flags and secret lookups work too.
You can write every probe by hand, or pull in a ready-made one. Each factory takes a client or a URL — never the environment — and validates its options at construction, so a missing URL throws a ProbeConfigError naming the probe and the field instead of a bare Invalid URL from somewhere inside the library.
The package family
The core is one package in a family. Adapters mount it on your framework, probes know how to ping one dependency each, and hosting packages say which replica answered.
| Packages | |
|---|---|
| Adapters | health-hono, health-elysia, health-express, health-next, health-tanstack-start |
| Probes | health-drizzle, health-supabase, health-tinybird, health-turso, health-turso-serverless, health-unkey, health-upstash |
| Hosting | health-fly, health-koyeb, health-railway, health-vercel, health-cloudflare |
Every one of them is published under the @openstatus scope on both npm and JSR. The full table, with a quick start for each adapter, is in the repository README.
Options worth knowing
| Option | Default | What it does |
|---|---|---|
cacheMs | 5000 | Reuse the last ok report for this long; concurrent callers share one round. |
cacheFailuresMs | cacheMs | Same, for degraded and unhealthy. Set 0 so a poller sees recovery on its next tick instead of waiting out the cache. |
staleMs | 0 | Stale-while-revalidate: keep answering with the last report while one refresh runs in the background. |
timeoutMs | 5000 | Default per-probe timeout. A hung probe reports timeout. |
deadlineMs | — | Upper bound for the whole round, capping every probe's timeout. Set it below your prober's own timeout. |
exposeChecks | true | false returns only status and checkedAt. A function decides per request. |
extend | — | (report, ctx) => object merged into the body — a request id, a region, a server object from a hosting package. |
formatError | "generic" | Probe errors are masked by default. "message" surfaces the real one while developing. |
onReport | — | Called once per uncached round. Log it, emit a metric, page on unhealthy. |
Caution
deadlineMs is the one to set deliberately. Kubernetes readiness probes default to a one-second timeout, and Fly's HTTP checks to five — if the whole round can outlast the prober's patience, the prober records a timeout and never reads the report you worked to produce.
How we use it
Our API runs on Fly, behind an HTTP check with a five-second timeout. The /ping route is four probes and an extend that names the machine that answered:
export const pingRoute = healthRoute({
path: "/ping",
deadlineMs: 4_000,
probes: [
tursoProbe({ client: db.$client }),
tinybirdProbe({
baseUrl: env.TINYBIRD_URL,
skip: () => isTinybirdNoop(env.TINYBIRD_NOOP),
}),
unkeyProbe(),
upstashProbe({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
}),
],
extend: (_report, c) => ({
server: flyServer(),
requestId: c.get("requestId"),
}),
});
The same shape backs the dashboard and the status pages, where vercelExtend reports the deployment instead.
Monitor it with openstatus
A health endpoint is only worth writing if something reads it. Point an HTTP monitor at the URL and add a body assertion:
Body · Contains ·
"status":"ok"
That assertion is the point. degraded answers 200 by default — deliberately, so a non-critical outage does not take you out of a load balancer — which means a status-code assertion alone will never tell you about it. Asserting on the body catches degraded while it is still only degraded, and you hear about the failing cache before it becomes the failing checkout.
Learn more
- github.com/openstatusHQ/health — source, full package table, and issues
- jsr.io/@openstatus — API reference for every package
- HTTP monitor reference — assertions, regions, and timeouts
- Component Registry — our other open-source building blocks
Support
If you hit a problem, have a question, or want a probe or adapter we do not ship yet: