/api/health is public. No auth, no rate limit, no signature. That is deliberate, because the thing consuming it is an uptime monitor that has no credentials and no interest in acquiring any.
It also means every field in the response is a decision about what to tell an anonymous stranger who is curious about your infrastructure. Ours is live right now at cogniprep.app/api/health and it returns this:
{
"status": "healthy",
"timestamp": "2026-09-23T08:47:22.450Z",
"checks": {
"database": { "status": "healthy", "responseTime": 117 },
"redis": { "status": "configured" },
"environment": { "status": "healthy" },
"monitoring": { "status": "configured" }
}
}
Every one of those shapes is the second version. Here is what the first version said and why it changed.
The count, not the names
const requiredEnvVars = ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'CRON_SECRET'];
const missingEnvVars = requiredEnvVars.filter((v) => !process.env[v]);
checks.environment = {
status: missingEnvVars.length === 0 ? 'healthy' : 'unhealthy',
missingCount: missingEnvVars.length > 0 ? missingEnvVars.length : undefined,
};
if (missingEnvVars.length > 0) {
logError(`[health] Missing required environment variables: ${missingEnvVars.join(', ')}`);
}
The obvious implementation returns the array. It is more useful, it is what you want at 3am, and it is a disclosure.
STRIPE_WEBHOOK_SECRET missing does not mean a variable is unset. It means webhook signature verification cannot be performed, and it means it to anybody who asks. CRON_SECRET missing means the cron endpoints are not protected. A list of your unset secrets is a list of which specific security controls are currently off, served over HTTPS to anyone who types the URL.
So the count goes to the caller and the names go to logError. The count is enough for the only decision the monitor makes, which is whether to page. The names are in the log, where the person being paged is already looking.
Note that missingCount is undefined when nothing is missing, so JSON.stringify drops the key entirely. In the healthy case the response does not even hint that the check is counting anything.
The database error message is worth more to an attacker than to you
} catch (error) {
logError('[health] Database check failed', error);
checks.database = { status: 'unhealthy' };
isHealthy = false;
}
Database errors are famously chatty. A connection failure names the host, the port, the role it tried to authenticate as, and often the driver and its version. A health endpoint that echoes error.message is a reconnaissance endpoint that anybody can trigger by waiting for an outage.
Same split as above: the exception object to the logger, the word unhealthy to the caller.
Slow is a 200, broken is a 503
checks.database = {
status: dbResponseTime > 1000 ? 'degraded' : 'healthy',
responseTime: dbResponseTime,
};
degraded does not set isHealthy = false. A database taking 1.4 seconds returns HTTP 200 with "status": "degraded" in the body.
This is the field that decides whether your monitoring is usable. If slow returns 503, you get paged at 4am for a query that took 1.1 seconds during a backup window, you learn that the page means nothing, and then you miss the real one. The status code answers one question, "is this thing serving", and the body carries the nuance for a dashboard to graph.
responseTime is the only number in the whole response that is not a category, and it is the one worth graphing. Its trend tells you about an incident well before the boolean flips.
The timer that held the function open
This was a real bug and it is the most transferable part of the file:
let dbTimeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
db.execute('SELECT 1'),
new Promise((_, reject) => {
dbTimeout = setTimeout(() => reject(new Error('Database timeout')), 5000);
}),
]);
// ...
} finally {
if (dbTimeout) clearTimeout(dbTimeout);
}
Promise.race resolves as soon as the first promise settles. It does not cancel the others. There is no mechanism by which it could: a promise is not an operation, it is a notification about one.
So when SELECT 1 comes back in 117ms, the race resolves, and the 5 second timer is still pending. In a serverless function a pending timer is a live handle on the event loop, and the runtime does not consider the invocation finished while one exists. A health check that takes 117ms of work was occupying a function for 5 seconds, on every poll, forever.
The finally block is the fix, and it has to be finally rather than after the await, because the timeout path throws and would skip a cleanup line placed in the happy path. Which would be the one case where the timer actually fired, but the shape is what matters: any promise you race against a timer owns the job of clearing it on every exit.
Two of the four checks do not check anything
const hasRedis = process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN;
checks.redis = {
status: hasRedis ? 'configured' : 'not_configured',
warning: !hasRedis && process.env.NODE_ENV === 'production'
? 'Redis not configured in production' : undefined,
};
configured is an honest word. It says a URL and a token are present. It does not say Redis answered, because nothing pinged it.
That is a choice and it cuts both ways. The cost is obvious: Redis could be down and this says configured. The benefit is less obvious and I think it wins here. A health check that actively pings every dependency becomes a load generator the moment you point more than one monitor at it, from more than one region, at a frequency somebody picked without thinking about multiplication. It also starts failing for reasons that are not your application's fault, which trains everyone to ignore it.
The rule I would write down: check the things whose failure means your app is lying about being up. The database is one, because every request touches it. A cache and an error reporter degrade rather than break, so their presence in the response is documentation, not a probe.
The warning field carries the environment aware part. Missing Redis locally is the expected state and says nothing. Missing Redis in production is a sentence in the response body.
Caching would make the whole thing a lie
export const dynamic = 'force-dynamic';
export const revalidate = 0;
// ...
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }
Three separate opt-outs because there are three separate caches in the way: Next's own route cache, the CDN in front of it, and whatever the monitor runs through. Any one of them left in place turns this into an endpoint that cheerfully reports the health of five minutes ago, which is exactly the interval during which you needed to know.
An unauthenticated JSON endpoint on a static-friendly framework is the easiest thing in the world to accidentally cache, and the failure is silent and green.
No rate limit, and no wrapper
Every other API route in this codebase is exported through a wrapper that applies rate limiting, CSRF and auth. This one is a bare export async function GET().
That is not an oversight, it is the point of the endpoint. Rate limiting means asking the rate limiter, and the rate limiter is a dependency. A health check whose ability to answer depends on the health of one of the things it is reporting on can only tell you the truth while everything is fine.
The tradeoff is real: this endpoint can be hammered. It survives it because the work is one SELECT 1 and four process.env reads, and because the response is small. If it ever grows a check expensive enough that abuse matters, the right move is to make the check cheaper, not to put a dependency in front of the door.
See it
cogniprep.app/api/health is live. Open it and read the four checks.
The interesting exercise is to imagine each field's more helpful version and ask what it would tell a stranger. missingCount: 1 becomes missing: ["STRIPE_WEBHOOK_SECRET"]. status: "unhealthy" becomes the driver's connection error. Both are strictly more useful to you and to anybody else.
Then go and open your own /health or /api/health and see which version you shipped. If the response contains a hostname, a port, a package version or the name of a secret, that is the whole post.
Top comments (0)