GitHub - openstatusHQ/health: Tree-shakable health endpoints for JS servers. Zero-dependency core, adapters for Hono, Elysia, Express, Next.js and TanStack Start, probes for your databases and APIs. Deno, Node, Bun, edge.

GitHub

14 min read Original article ↗

A dependency-free core runs probes against your database, cache and upstream APIs and renders ok | degraded | unhealthy. Thin adapters mount it as GET /health on Hono, Elysia, Express, Next.js, TanStack Start or any Fetch-API server. Thin probe packages know how to ping one dependency each.

Runs on Deno, Node ≥ 22, Bun and edge runtimes. Published to JSR and npm. These are the /health endpoints behind openstatus's own services, extracted so any server can expose one.

Quick start

Install the core, one adapter and the probes you need:

npm install @openstatus/health @openstatus/health-hono @openstatus/health-turso
# or
deno add jsr:@openstatus/health jsr:@openstatus/health-hono jsr:@openstatus/health-turso

Mount the route:

import { Hono } from "hono";
import { createClient } from "@libsql/client";
import { healthRoute } from "@openstatus/health-hono";
import { tursoProbe } from "@openstatus/health-turso";
import { unkeyProbe } from "@openstatus/health-unkey";

const client = createClient({ url: process.env.TURSO_URL!, authToken: process.env.TURSO_TOKEN });

const app = new Hono();
app.route("/", healthRoute({ probes: [tursoProbe({ client }), unkeyProbe()] }));

export default app;

GET /health answers with the aggregate status and one entry per probe:

{
  "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" }
  ]
}

ok and degraded answer 200, unhealthy answers 503; both codes are configurable. A failing or timed-out critical probe makes the report unhealthy, a failing non-critical probe makes it degraded, and skipped probes never affect it. Responses are sent with Cache-Control: no-store.

Why @openstatus/health

  • Zero dependencies in the core. The probe runner, cache and renderer have no runtime dependencies, so the endpoint you add is the endpoint you audit.
  • One package per concern, tree-shakable by construction. Import @openstatus/health-hono without pulling Express; import @openstatus/health-unkey without pulling Drizzle. CI bundles a one-line consumer of every package and fails if an unrelated library lands in the output.
  • Same behaviour on every framework. Adapters contain no response logic; they mount one core, so status codes, caching and rendering are identical whether you run Hono on Bun or Express on Node.
  • Probes never read your environment. A probe takes a client or a URL, so it is testable and shareable; optional dependencies report skipped instead of failing when they are not configured.
  • Built for real infrastructure. Per-probe timeouts, a whole-round deadlineMs, caching, stale-while-revalidate, and separate liveness and readiness routes.
  • Know which replica answered. Hosting packages add region, instanceId, service and version under server through the extend hook.

Every adapter exports healthRoute(options), the batteries-included form that mounts GET and HEAD on options.path (default /health). All but Next.js also export healthHandler(options) — a plain handler for that framework — for when you want to pick the path, stack your own middleware in front, or register it the way you register everything else.

Hono
import { Hono } from "hono";
import { healthHandler, healthRoute } from "@openstatus/health-hono";
import { tursoProbe } from "@openstatus/health-turso";
import { unkeyProbe } from "@openstatus/health-unkey";

const app = new Hono();

app.route("/", healthRoute({
  probes: [tursoProbe({ client }), unkeyProbe()],
  extend: (_report, c) => ({ requestId: c.get("requestId") }),
}));

// or, on a route of your own:
app.on(["GET", "HEAD"], "/health", healthHandler({ probes: [unkeyProbe()] }));
Elysia
import { Elysia } from "elysia";
import { healthRoute } from "@openstatus/health-elysia";
import { tinybirdProbe } from "@openstatus/health-tinybird";

new Elysia().use(healthRoute({ probes: [tinybirdProbe()] })).listen(3000);
Express
import express from "express";
import { healthRoute } from "@openstatus/health-express";
import { drizzleProbe } from "@openstatus/health-drizzle";

const app = express();
app.use(healthRoute({ probes: [drizzleProbe({ db })] }));
Next.js (App Router)
// app/health/route.ts
import { healthRoute } from "@openstatus/health-next";
import { supabaseProbe } from "@openstatus/health-supabase";

// required: keeps Next.js from statically caching the route
export const dynamic = "force-dynamic";

export const { GET, HEAD } = healthRoute({ probes: [supabaseProbe({ client })] });
TanStack Start
// src/routes/api/health.ts
import { createFileRoute } from "@tanstack/react-router";
import { healthRoute } from "@openstatus/health-tanstack-start";
import { supabaseProbe } from "@openstatus/health-supabase";

export const Route = createFileRoute("/api/health")({
  server: { handlers: healthRoute({ probes: [supabaseProbe({ client })] }) },
});
Anything with a Fetch API (Deno.serve, Bun.serve, Workers)
import { createHealthHandler } from "@openstatus/health";

Deno.serve(createHealthHandler({ path: "/health", probes: [/* ... */] }));

Runnable projects for each adapter live in examples/.

Packages

Core

Package JSR npm Description
@openstatus/health JSR npm Probe runner, caching, response rendering, Fetch-API handler

Server adapters

Package JSR npm Description
@openstatus/health-hono JSR npm Hono adapter
@openstatus/health-elysia JSR npm Elysia adapter
@openstatus/health-express JSR npm Express 4 / 5 adapter
@openstatus/health-next JSR npm Next.js App Router adapter
@openstatus/health-tanstack-start JSR npm TanStack Start adapter

Probes

Databases

Package JSR npm Description
@openstatus/health-clickhouse JSR npm ClickHouse ping / SELECT 1 probe (@clickhouse/client)
@openstatus/health-cloudflare-d1 JSR npm Cloudflare D1 select 1 probe over the Workers binding
@openstatus/health-convex JSR npm Convex query probe over the deployment HTTP API
@openstatus/health-drizzle JSR npm Drizzle ORM select 1 probe
@openstatus/health-mongodb JSR npm MongoDB ping command probe (mongodb)
@openstatus/health-mysql JSR npm MySQL / MariaDB select 1 probe (mysql2/promise)
@openstatus/health-neon JSR npm Neon serverless Postgres select 1 probe (@neondatabase/serverless)
@openstatus/health-planetscale JSR npm PlanetScale select 1 probe over the serverless driver (@planetscale/database)
@openstatus/health-postgres JSR npm Postgres select 1 probe (pg, postgres.js, Neon, Vercel Postgres)
@openstatus/health-prisma JSR npm Prisma select 1 / ping probe (@prisma/client)
@openstatus/health-supabase JSR npm Supabase connection-pressure probe
@openstatus/health-tinybird JSR npm Tinybird reachability probe
@openstatus/health-turso JSR npm Turso libSQL select 1 probe (@libsql/client)
@openstatus/health-turso-serverless JSR npm Turso select 1 probe over the serverless driver (@tursodatabase/serverless)

Caches & KV

Package JSR npm Description
@openstatus/health-cloudflare-kv JSR npm Cloudflare Workers KV read probe over the Workers binding
@openstatus/health-redis JSR npm Redis / Valkey PING probe (node-redis, ioredis, @upstash/redis)
@openstatus/health-upstash JSR npm Upstash Redis PING probe over REST

Object storage

Package JSR npm Description
@openstatus/health-cloudflare-r2 JSR npm Cloudflare R2 HEAD probe over the Workers binding
@openstatus/health-s3 JSR npm S3 HeadBucket probe (@aws-sdk/client-s3; AWS, R2, Tigris, MinIO)

Queues & workflows

Package JSR npm Description
@openstatus/health-bullmq JSR npm BullMQ waiting-count / backlog probe (bullmq)
@openstatus/health-inngest JSR npm Inngest REST API reachability probe
@openstatus/health-qstash JSR npm Upstash QStash reachability probe over REST
@openstatus/health-trigger-dev JSR npm Trigger.dev API reachability probe

Messaging

Package JSR npm Description
@openstatus/health-kafka JSR npm Kafka describeCluster() probe (kafkajs)
@openstatus/health-nats JSR npm NATS flush() round-trip probe (@nats-io/*, nats)

Search

Package JSR npm Description
@openstatus/health-algolia JSR npm Algolia /1/isalive reachability probe
@openstatus/health-meilisearch JSR npm Meilisearch /health probe (cloud or self-hosted)
@openstatus/health-typesense JSR npm Typesense /health probe (cloud or self-hosted)

SaaS APIs

Package JSR npm Description
@openstatus/health-anthropic JSR npm Anthropic API reachability probe
@openstatus/health-clerk JSR npm Clerk Backend API reachability probe
@openstatus/health-openai JSR npm OpenAI API reachability probe (or any OpenAI-compatible host)
@openstatus/health-posthog JSR npm PostHog API reachability probe (cloud or self-hosted)
@openstatus/health-resend JSR npm Resend API reachability probe
@openstatus/health-sentry JSR npm Sentry API reachability probe (SaaS or self-hosted)
@openstatus/health-stripe JSR npm Stripe API reachability probe
@openstatus/health-unkey JSR npm Unkey liveness probe
@openstatus/health-workos JSR npm WorkOS API reachability probe

Network & protocols

Package JSR npm Description
@openstatus/health-dns JSR npm DNS lookup probe for a hostname (node:dns)
@openstatus/health-grpc JSR npm gRPC Health/Check probe through a @grpc/grpc-js health client
@openstatus/health-tcp JSR npm TCP connect probe for any host:port (node:net)
@openstatus/health-tls JSR npm TLS handshake, trust and certificate-expiry probe (node:tls)

System

Package JSR npm Description
@openstatus/health-disk JSR npm Free disk space threshold probe (node:fs statfs)
@openstatus/health-memory JSR npm Heap / RSS memory pressure probe (node:process, node:v8)

Hosting metadata

Package JSR npm Description
@openstatus/health-fly JSR npm Fly.io region, machine and deployment
@openstatus/health-koyeb JSR npm Koyeb region, instance and deployment
@openstatus/health-railway JSR npm Railway region, replica, environment and deployment
@openstatus/health-vercel JSR npm Vercel region, environment and deployment
@openstatus/health-cloudflare JSR npm Cloudflare Workers colo and version metadata

Missing a framework or a dependency? Adapters and probes are small — open an issue or send a PR; AGENTS.md walks through adding a package.

Probes

Databases

Probe Default name Critical Checks
clickhouseProbe({ client, select? }) clickhouse no client.ping({ select: true }) on a @clickhouse/client client
convexProbe({ url, path, args?, token? }) database yes POST {url}/api/query running path answers status: "success"
d1Probe({ db }) database yes db.prepare("select 1").first() on a Workers D1Database binding
drizzleProbe({ db }) database yes db.execute(sql\select 1`)ordb.run(...)`
mongodbProbe({ client, db? }) database yes client.db("admin").command({ ping: 1 }) on a MongoClient
mysqlProbe({ client }) database yes client.query("select 1") on a mysql2/promise pool or connection
neonProbe({ client }) database yes client.query("select 1") on neon(), or a Neon Pool / Client
planetscaleProbe({ connection }) database yes connection.execute("select 1") on a @planetscale/database connection
postgresProbe({ client }) database yes client.query("select 1") on a pg pool, or sql.unsafe("select 1") on postgres.js
prismaProbe({ client }) database yes client.$queryRawUnsafe("select 1"), or client.$runCommandRaw({ ping: 1 }) on MongoDB
supabaseProbe({ client, maxConnectionPercent? }) supabase no rpc("health_connection_pressure") ≤ threshold
tinybirdProbe({ baseUrl? }) tinybird no GET {baseUrl}/v0/health
tursoProbe({ client }) database yes client.execute("select 1") on a Turso libSQL client
tursoServerlessProbe({ connection }) database yes connection.get("select 1") on a Turso serverless Connection

Caches & KV

Probe Default name Critical Checks
kvProbe({ namespace, key? }) kv no namespace.get("health") on a Workers KVNamespace binding
redisProbe({ client }) redis no client.ping() answers PONG on a node-redis, ioredis or Upstash client
upstashProbe({ url, token }) redis no GET {url}/ping with the REST token

Object storage

Probe Default name Critical Checks
r2Probe({ bucket, key? }) storage no bucket.head(key ?? "health") on a Workers R2Bucket binding
s3Probe({ client, bucket }) storage no client.send(new HeadBucketCommand({ Bucket })) on an S3Client

Queues & workflows

Probe Default name Critical Checks
bullmqProbe({ queue, maxWaiting? }) queue no queue.getWaitingCount() on a BullMQ Queue, ≤ threshold
inngestProbe({ signingKey, baseUrl? }) inngest no GET {baseUrl}/v1/events?limit=1 with the signing key
qstashProbe({ token, baseUrl? }) qstash no GET {baseUrl}/v2/queues with the token
triggerDevProbe({ secretKey, baseUrl? }) trigger no GET {baseUrl}/api/v1/runs?page[size]=1 with the secret key

Messaging

Probe Default name Critical Checks
kafkaProbe({ admin }) kafka no admin.describeCluster() lists ≥ 1 broker on a connected KafkaJS Admin
natsProbe({ connection }) nats no connection.flush() on an open NatsConnection

Search

Probe Default name Critical Checks
algoliaProbe({ appId, apiKey, baseUrl? }) search no GET {baseUrl}/1/isalive with the app headers; baseUrl defaults to https://{appId}-dsn.algolia.net
meilisearchProbe({ host, apiKey? }) search no GET {host}/health answers status: "available"
typesenseProbe({ host, apiKey? }) search no GET {host}/health answers ok: true

SaaS APIs

Probe Default name Critical Checks
anthropicProbe({ apiKey, baseUrl? }) anthropic no GET {baseUrl}/v1/models with the x-api-key header
clerkProbe({ secretKey, baseUrl? }) clerk no GET {baseUrl}/v1/users?limit=1 with the secret key
openaiProbe({ apiKey, baseUrl? }) openai no GET {baseUrl}/v1/models with the API key
posthogProbe({ personalApiKey, baseUrl? }) posthog no GET {baseUrl}/api/projects/@current/ with the personal API key
resendProbe({ apiKey, baseUrl? }) resend no GET {baseUrl}/domains with the API key
sentryProbe({ token?, baseUrl? }) sentry no GET {baseUrl}/api/0/, with the auth token when given
stripeProbe({ secretKey, baseUrl? }) stripe no GET {baseUrl}/v1/balance with the secret key
unkeyProbe({ baseUrl? }) unkey no GET {baseUrl}/v2/liveness
workosProbe({ apiKey, baseUrl? }) workos no GET {baseUrl}/organizations?limit=1 with the API key

Network & protocols

Probe Default name Critical Checks
dnsProbe({ hostname, lookup? }) dns no dns.promises.lookup(hostname) returns an address
grpcProbe({ client, service? }) grpc no client.check({ service }) answers SERVING on a grpc.health.v1.Health client
tcpProbe({ host, port }) tcp no a TCP connection to host:port is accepted
tlsProbe({ host, port?, minDaysValid? }) tls no a TLS handshake with a trusted certificate valid ≥ minDaysValid days

System

Probe Default name Critical Checks
diskProbe({ path?, minFreePercent?, minFreeBytes? }) disk no free space of the filesystem holding path ≥ threshold
memoryProbe({ maxHeapUsedPercent?, maxRssBytes? }) memory no heap in use ≤ 90% of the V8 heap limit by default; RSS ≤ budget only when maxRssBytes is set

Every probe factory accepts name, critical, timeoutMs and skip overrides. Probes take a client instance, a base URL or, for tcpProbe, a host and port — they never read process.env themselves.

Writing your own probe

A probe is a plain object. Resolve for healthy, reject or throw for failed, and honour the AbortSignal so a timeout actually cancels the work:

import { httpProbe, probe } from "@openstatus/health";

const queue = probe({
  name: "queue",
  critical: true,
  timeoutMs: 1000,
  skip: () => !env.QUEUE_URL,
  run: async (signal, ctx) => {
    const res = await fetch(`${env.QUEUE_URL}/depth`, { signal });
    if (!res.ok) throw new Error(`${ctx.name} answered ${res.status}`);
    const { depth } = await res.json();
    if (depth > 10_000) throw new Error(`queue depth ${depth}`);
  },
});

const docs = httpProbe({ name: "docs", url: "https://docs.example.com", method: "HEAD" });

skip runs on every request, may be async, and reports the check as skipped without running it — use it for optional dependencies that are not configured in every environment. ctx carries the probe's name, critical flag and effective timeoutMs.

@openstatus/health/testing exports fakeFetch, hangFetch and ready-made okProbe / failingProbe / hangingProbe fixtures for testing probes and adapters of your own.

Options

Every entry point takes the same options — probes (or a shared check), path, cacheMs, cacheFailuresMs, staleMs, timeoutMs, deadlineMs, exposeChecks, unhealthyStatusCode, degradedStatusCode, extend, formatError, onReport, onError — documented once in packages/health.

Errors are masked as "failed" unless you opt in with formatError: "message".

Liveness, readiness, public and internal

Liveness is "the process answers"; readiness is "the process can serve". Mount the same adapter twice — an empty probe list is always ok:

app.route("/", healthRoute({ path: "/livez", probes: [] }));
app.route("/", healthRoute({ path: "/readyz", probes, deadlineMs: 800, cacheFailuresMs: 0 }));

deadlineMs caps the whole round so a hung dependency cannot outlast a Kubernetes probe's timeoutSeconds; cacheFailuresMs: 0 lets the next poll see a recovery immediately. Set staleMs to keep answering from the last report while a refresh runs in the background.

One /health can serve the load balancer and your on-call engineer: exposeChecks takes a function of the request, and extend output is only rendered when checks are exposed. If you would rather serve two routes, build the check once and share it so the probes run once per cache window:

import { createHealthCheck } from "@openstatus/health";

const check = createHealthCheck({ probes, cacheMs: 5000, onReport: log });

app.route("/", healthRoute({ check, exposeChecks: false }));
app.route("/", healthRoute({ check, path: "/_health", extend: flyExtend() }));
// or, one route:
app.route("/", healthRoute({
  check,
  exposeChecks: (c) => c.req.header("x-health-token") === env.HEALTH_TOKEN,
  extend: flyExtend(),
}));

check.invalidate() drops the cache — call it after a reconnect or a config reload.

Server metadata

The hosting packages answer a different question from the probes: not "is the database up" but "which replica is telling me that". Each reads its platform's own environment — or, on Workers, the request — and renders it under server through the same extend hook:

import { healthRoute } from "@openstatus/health-hono";
import { flyExtend } from "@openstatus/health-fly";

app.route("/", healthRoute({ probes, extend: flyExtend() }));
{
  "status": "ok",
  "checkedAt": "2026-09-11T12:00:00.000Z",
  "latencyMs": 41,
  "checks": [{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 }],
  "server": {
    "platform": "fly",
    "region": "ams",
    "instanceId": "148e21ebd47089",
    "service": "openstatus-api",
    "version": "registry.fly.io/openstatus-api:deployment-01H9RK9EYO9PGNBYAKGXSHV0PH",
    "primaryRegion": "cdg"
  }
}

platform, region, instanceId, service, version and environment mean the same thing on every platform; anything else is named as that platform names it. A field is absent rather than guessed when the platform has no equivalent — Vercel exposes no instance identity, so there is no instanceId there. Values are passed through exactly as the platform sets them, so region is ams on Fly and DFW on Cloudflare.

Each package also exports the data on its own — flyServer(), vercelServer() — so you can compose it with your own fields, or chain platforms if one build deploys to several. extend may return anything JSON.stringify accepts; the report's own fields always take precedence over keys of the same name:

extend: (_report, c) => ({
  server: flyServer() ?? vercelServer(),
  requestId: c.get("requestId"),
}),

Off-platform they return undefined and nothing is rendered, so the same build runs unchanged on your laptop. extend follows exposeChecks: when the checks are hidden, so is everything extend adds.

Monitoring the endpoint

A /health that reports degraded is only useful if something reads it. Point an openstatus HTTP monitor at the endpoint and assert on status in the body to be alerted on degraded before it becomes unhealthy. Any poller that checks the HTTP status code — Kubernetes, Fly, Railway, a load balancer — gets 503 on unhealthy without further configuration.

Development

deno task check            # type-check, lint, fmt, version consistency
deno task test             # node:test suites under Deno
deno task build            # tsdown -> dist/ for every package
deno task test:node        # the same suites under Node against dist/
deno task check:treeshake  # no package bundles another framework/client
deno task test-all         # all of the above

See AGENTS.md for layout and conventions, CHANGES.md for the changelog and RELEASING.md for the release checklist.

Contributing

Issues and PRs are welcome — a bug, a new adapter or probe, or a docs fix.

  • Read AGENTS.md for the layout and conventions.
  • Run deno task test-all before opening a PR.
  • Join the Discord to ask questions.

About openstatus

openstatus is an open-source uptime monitoring and status page platform. It monitors endpoints from regions around the world and turns the results into status pages and alerts. These packages are the /health endpoints behind openstatus's own services, extracted so any JavaScript server can expose one — and so a monitor has something more useful to poll than 200 OK.

License

MIT