A health check endpoint is one of the most important pieces of infrastructure in any API service — and one of the most commonly implemented wrong.
Done well, a /health endpoint gives your monitoring tools, orchestrators, and load balancers an accurate picture of your service's state in milliseconds. Done poorly, it either masks real failures by always returning 200, or triggers false alerts by surfacing transient issues as critical failures.
This guide covers what to include in a health check endpoint, standardised response formats, the difference between liveness and readiness, dependency checks, response time targets, and how to wire it into external monitoring with Vigilmon.
What to Include in a Health Check Endpoint
A minimal health check that just returns 200 OK is better than nothing. But a well-designed health check endpoint communicates significantly more:
1. Overall status — is the service able to handle traffic right now?
2. Dependency status — which downstream dependencies are healthy, degraded, or down?
3. Version and build info — useful for verifying the right version is deployed.
4. Response time of dependency checks — how long did it take to query the database or cache?
5. Uptime — how long has the service been running since last restart?
Standardised JSON Response Schema
The IETF has a draft standard for health check responses (RFC draft: Health Check Response Format), and it's worth following. A well-structured health response looks like this:
{
"status": "pass",
"version": "1.4.2",
"releaseId": "20260615.1",
"uptime": 86400,
"checks": {
"database:responseTime": [
{
"status": "pass",
"time": 4,
"unit": "ms"
}
],
"cache:responseTime": [
{
"status": "pass",
"time": 1,
"unit": "ms"
}
],
"storage:diskSpace": [
{
"status": "warn",
"output": "Disk usage at 78%",
"unit": "percent"
}
]
}
}
Status values:
pass— healthywarn— degraded but operationalfail— unhealthy, should not receive traffic
HTTP status codes to match:
pass→200 OKwarn→200 OK(or207 Multi-Status)fail→503 Service Unavailable
Return 503 when your service is genuinely unable to serve traffic. This tells load balancers and orchestrators to stop routing to this instance.
Liveness vs. Readiness Checks
Kubernetes popularised the distinction between liveness and readiness probes, and it applies beyond Kubernetes to any service with complex startup or dependency requirements.
Liveness Check
Question it answers: Is the process alive and not deadlocked?
A liveness check should be extremely fast and check nothing external. It's purely about whether the process is running and responding. If the liveness check fails, the orchestrator restarts the process.
GET /health/live
→ 200 OK
{"status": "pass"}
Liveness checks should never check the database, cache, or any external service. If your database is down, you want your service to stay running (and return errors or serve cached data) — not be killed and restarted repeatedly.
Readiness Check
Question it answers: Is the service ready to receive traffic?
A readiness check verifies that all required dependencies are reachable and the service has completed startup. If readiness fails, the load balancer stops routing traffic to this instance without restarting it.
GET /health/ready
→ 200 OK or 503
{"status": "pass", "checks": {...}}
Readiness checks should include dependency verification. A service that can't reach its database shouldn't receive traffic.
Dependency Checks
For each critical dependency, your health check should verify connectivity and measure response time. Keep these checks lightweight — you're not running a full integration test, you're doing a quick ping.
Database: Run a simple query (SELECT 1 or equivalent). Measure the round-trip time.
Cache (Redis): Use PING. Expect PONG back in under 1ms on a healthy instance.
Message queue: Check if the broker connection is alive and the consumer is attached.
External APIs: Only include if they're hard dependencies — services you can't function without. Include circuit breaker state if you're using one.
Code Examples
Node.js (Express)
const express = require('express');
const app = express();
app.get('/health/ready', async (req, res) => {
const checks = {};
let overall = 'pass';
// Database check
const dbStart = Date.now();
try {
await db.query('SELECT 1');
checks['database:responseTime'] = [{ status: 'pass', time: Date.now() - dbStart, unit: 'ms' }];
} catch (err) {
checks['database:responseTime'] = [{ status: 'fail', output: err.message }];
overall = 'fail';
}
// Cache check
const cacheStart = Date.now();
try {
await redis.ping();
checks['cache:responseTime'] = [{ status: 'pass', time: Date.now() - cacheStart, unit: 'ms' }];
} catch (err) {
checks['cache:responseTime'] = [{ status: 'warn', output: err.message }];
if (overall === 'pass') overall = 'warn';
}
const statusCode = overall === 'fail' ? 503 : 200;
res.status(statusCode).json({ status: overall, checks, version: process.env.APP_VERSION });
});
Python (FastAPI)
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import time
app = FastAPI()
@app.get("/health/ready")
async def readiness():
checks = {}
overall = "pass"
# Database check
start = time.time()
try:
await db.execute("SELECT 1")
checks["database:responseTime"] = [{"status": "pass", "time": round((time.time() - start) * 1000), "unit": "ms"}]
except Exception as e:
checks["database:responseTime"] = [{"status": "fail", "output": str(e)}]
overall = "fail"
status_code = 503 if overall == "fail" else 200
return JSONResponse(status_code=status_code, content={"status": overall, "checks": checks})
Go
func healthReadyHandler(w http.ResponseWriter, r *http.Request) {
checks := map[string]interface{}{}
overall := "pass"
// Database check
start := time.Now()
if err := db.PingContext(r.Context()); err != nil {
checks["database:responseTime"] = []map[string]interface{}{
{"status": "fail", "output": err.Error()},
}
overall = "fail"
} else {
checks["database:responseTime"] = []map[string]interface{}{
{"status": "pass", "time": time.Since(start).Milliseconds(), "unit": "ms"},
}
}
statusCode := http.StatusOK
if overall == "fail" {
statusCode = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": overall,
"checks": checks,
})
}
Response Time Targets
Your health check endpoint should respond fast. If the health check itself is slow, orchestrators may timeout and incorrectly mark the service as unhealthy.
| Check type | Target response time | |-----------|---------------------| | Liveness | < 5ms | | Readiness (no dependencies) | < 10ms | | Readiness (with DB check) | < 100ms | | Readiness (with DB + cache) | < 150ms |
If your readiness check takes more than 200ms to respond under normal conditions, that's a signal that your dependency checks themselves are slow — worth investigating.
Use timeouts in your dependency checks rather than letting them block indefinitely:
const dbResult = await Promise.race([
db.query('SELECT 1'),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000))
]);
Setting Up Vigilmon API Monitoring
Once your health check endpoint is live, external monitoring with Vigilmon provides a second layer of verification from outside your infrastructure.
Key Vigilmon settings for API health monitoring:
- Monitor your
/health/readyendpoint — not just/or your main API path - Set check interval to 1 minute — catches issues within 60 seconds
- Assert on HTTP status code — expect
200, alert on503 - Optional: assert on response body — check that
"status":"pass"appears in the response - Enable multi-region consensus — Vigilmon's default ensures alerts only fire when your endpoint is genuinely down, not when a single probe has a bad moment
Vigilmon's multi-region consensus is particularly valuable for API monitoring because APIs are more likely than static sites to have region-specific routing issues. Getting a consensus-confirmed failure means the problem is real, not a network blip between one probe and your load balancer.
Common Mistakes to Avoid
Returning 200 from a /health endpoint unconditionally. A health check that never returns 503 is useless for orchestration. If your service can't reach its database, it should say so.
Including slow checks in liveness probes. Liveness checks that query the database will cause cascading restarts when the database is slow. Keep liveness checks process-local only.
Not setting timeouts on dependency checks. A database that stops responding will hang your health check indefinitely, making your service appear healthy to anything that never receives a response.
Exposing sensitive internal data. Health check endpoints are often publicly accessible. Don't include internal IPs, connection strings, or secrets in the response output.
Conclusion
A well-designed health check endpoint is a contract between your service and its infrastructure. It tells load balancers when to route traffic, tells orchestrators when to restart instances, and tells external monitors when something is genuinely wrong.
The key decisions: separate liveness from readiness, check dependencies in readiness probes (with timeouts), return 503 when the service is unhealthy, and follow a consistent response schema.
External monitoring with Vigilmon gives you a verification layer that's independent of your infrastructure — confirming your API is reachable from the outside world, not just internally healthy.
Start monitoring your API endpoints free: vigilmon.online