Photo by Levart_Photographer on Unsplash
A health check endpoint is the smallest possible piece of code with an outsized impact on how fast you find out something is broken. Get it wrong and you'll either miss real outages or drown in false alarms at 3 AM. Get it right and it becomes the backbone of your monitoring, your load balancer routing, your Kubernetes orchestration, and your incident response.
Most teams write a /health route in five minutes, return { status: "ok" }, and move on. That works fine until you're debugging a partial outage where the endpoint says everything is fine but customers are getting 500 errors. This guide covers how to design health check endpoints that actually tell you the truth, without becoming a liability themselves.
What is a Health Check Endpoint and Why It Matters
A health check endpoint is a dedicated HTTP route, typically /health, /healthz, or /status, that reports whether a service is running and capable of doing its job. It's not a feature for end users. It's infrastructure plumbing that other systems query: load balancers, container orchestrators, uptime monitors, and your own internal dashboards.
The purpose sounds simple: answer the question "is this thing working?" But that question has layers. Is the process running? Is it accepting connections? Can it actually serve a request end to end, including talking to its database? Good health check endpoint design is about deciding which of those questions you're answering, and making that decision explicit rather than accidental.
Health checks power uptime monitoring by giving external tools a predictable, lightweight way to poll your service. Instead of hitting your homepage (which might load ads, run analytics scripts, and query six different services), a monitoring tool hits /health and gets a fast, deterministic answer. That answer feeds directly into your incident response pipeline. When a health check fails, it can trigger a page, restart a container, remove an instance from a load balancer pool, or open an incident automatically. The endpoint is the trigger point for a whole chain of automated and human reactions.
For small teams, this matters even more than it does at large companies, because you don't have a dedicated SRE team watching dashboards all day. You need your systems to detect problems and alert you before customers do. A well-designed health check endpoint is one of the cheapest investments you can make in reliability. It costs a few hours to build properly and pays back every time it catches a database connection leak or a stuck worker process before it becomes a customer-facing incident.
There's also a scaling argument. If you're planning to grow, whether that's more traffic, more services, or a move to container orchestration, health checks are a prerequisite. Kubernetes won't route traffic sensibly without liveness and readiness probes. Load balancers won't fail over correctly without a working health endpoint. You can retrofit this later, but it's much easier to build it in from the start, especially since it's cheap to do right the first time.
Key Components of Effective Health Check Endpoint Design
Good health check endpoint design isn't complicated, but it does require intentional choices in a handful of areas.
HTTP Status Codes and Response Formats
The status code is the first and most important signal. A healthy service should return 200 OK. An unhealthy service should return something in the 5xx range, typically 503 Service Unavailable, which tells load balancers and monitoring tools "don't send traffic here right now."
Avoid using 200 for everything and burying the real status in the response body. Many automated systems, load balancers especially, only look at the status code. If you return 200 with a JSON body saying "status": "unhealthy", a dumb load balancer will happily keep routing traffic to a broken instance. The status code and the body should always agree.
Some teams use 429 or 404 for degraded states, but this is generally a bad idea because those codes have other established meanings. Stick to 200 for healthy and 503 for unhealthy. If you need a middle state (degraded but still serving traffic), that's a body-level detail, not a status code choice, and you should design for it carefully since not every consumer of your endpoint will parse the body.
What Dependencies to Monitor
This is where health check endpoint design gets opinionated. You have to decide what "healthy" means for your service. At minimum, most services should check:
- Database connectivity (can you open a connection or run a trivial query)
- Cache layer availability (Redis, Memcached) if your app can't function without it
- Critical external APIs you depend on for core functionality (payment processors, auth providers)
- Disk space or memory if you're running stateful workloads
Not every dependency belongs in your health check. If your app can degrade gracefully without a third-party analytics service, don't fail your health check because that service is down. The rule of thumb: only include a dependency if its failure means your service genuinely cannot do its job. Otherwise you'll get cascading failures where one flaky vendor takes down your entire fleet in monitoring dashboards, even though real users are unaffected.
Response Time Considerations and Timeout Thresholds
Health checks need to be fast. If your monitoring tool or load balancer times out waiting for a health check response, it'll treat the service as down, which might be technically true but for the wrong reasons (the health check itself is slow, not the service).
A good target is under 100ms for a shallow check and under 1 second for a deep check that touches dependencies. Set explicit timeouts inside your health check logic, not just at the HTTP client level. If a database query hangs for 30 seconds, your health check shouldn't hang with it. Wrap dependency calls with a short timeout (200-500ms is reasonable) and treat a timeout as a failure for that specific check.
Authentication and Security for Health Check Endpoints
Health check endpoints are often left unauthenticated because internal tools and load balancers need to hit them without credentials. That's usually fine, but it means you should be careful about what information you expose. Don't leak internal hostnames, database connection strings, stack traces, or version numbers in your health check response unless you've deliberately decided that's acceptable for your threat model.
If your health check endpoint exposes detailed dependency status (which is useful for debugging), consider putting the verbose version behind authentication or an internal-only network path, and keeping a minimal public version for external monitors and load balancers. Two endpoints, two audiences: /health for the simple public check, /health/detailed or /internal/health for the version with dependency breakdowns, locked down to internal IPs or an API key.
JSON vs. Plain Text Response Structures
For simple shallow checks, plain text ("OK") is fine and slightly faster to generate and parse. But once you're checking multiple dependencies, JSON is the better choice because it lets you communicate partial failure clearly:
{
"status": "healthy",
"timestamp": "2026-01-15T10:32:00Z",
"checks": {
"database": { "status": "healthy", "latency_ms": 12 },
"redis": { "status": "healthy", "latency_ms": 3 },
"payment_api": { "status": "degraded", "latency_ms": 890 }
}
}
This structure is human-readable during an incident, machine-parseable for dashboards, and extensible as you add more dependencies. Keep field names consistent across services if you run more than one, since that consistency is what lets you build a single dashboard or alerting rule set across your whole stack.
Health Check Endpoint Patterns: Shallow vs. Deep Checks
One of the most important decisions in health check endpoint design is choosing between shallow and deep checks, or more likely, using both.
Shallow Health Checks
A shallow health check answers one question: is the process running and able to accept HTTP requests? It doesn't touch a database, doesn't call any external service, and doesn't do any real work. It just returns 200 OK immediately.
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'ok' });
});
This is exactly what Kubernetes liveness probes want. A liveness probe exists to answer "should this container be restarted?" and the answer to that should only be based on whether the process itself is stuck or crashed, not whether a downstream database is having a bad day. If you put a database check in your liveness probe, a database outage will cause Kubernetes to restart every pod in your deployment, which does nothing to fix the database and makes your outage worse.
Deep Health Checks
A deep health check verifies that the service can actually do its job, checking database connectivity, cache availability, and critical dependencies. This is what you want for readiness probes, uptime monitoring, and pre-deployment validation.
app.get('/health/ready', async (req, res) => {
const checks = {};
let healthy = true;
try {
await db.query('SELECT 1');
checks.database = { status: 'healthy' };
} catch (e) {
checks.database = { status: 'unhealthy', error: e.message };
healthy = false;
}
try {
await redis.ping();
checks.redis = { status: 'healthy' };
} catch (e) {
checks.redis = { status: 'unhealthy', error: e.message };
healthy = false;
}
res.status(healthy ? 200 : 503).json({
status: healthy ? 'healthy' : 'unhealthy',
checks,
});
});
Deep checks are what your external uptime monitor should poll, since they actually tell you whether customers can use your product. If you're using something like Uptiqr to monitor uptime, pointing it at your deep health check endpoint rather than your homepage gives you a faster, more precise signal about real service health without the noise of unrelated frontend assets.
When to Use Each Pattern
For small teams, the practical rule is: use shallow checks for anything that controls automatic restarts (Kubernetes liveness, process managers), and use deep checks for anything that controls traffic routing or external alerting (Kubernetes readiness, load balancer health checks, uptime monitors). If you're not running Kubernetes and just have a single API server behind a load balancer, you likely only need one deep check endpoint, but keep the shallow/deep distinction in mind in case you scale into orchestration later.
Combining Approaches
Most mature setups expose two or three endpoints:
/healthz, shallow, for liveness probes and quick pings/health/readyor/readyz, deep, for readiness probes and load balancer decisions/health/detailed, deep with full diagnostic output, for internal dashboards and on-call debugging, often behind auth
This layered approach avoids the single biggest trade-off in health check design: speed versus information. A single endpoint trying to be both fast and comprehensive will fail at one of those goals. Splitting them lets each endpoint do one job well.
Implementing Health Check Endpoints Across Your Stack
Node.js/Express Applications
Express makes this straightforward since you're just defining routes. The pattern above works well; the main thing to add for production use is a timeout wrapper so a hanging dependency doesn't hang your health check:
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
app.get('/health/ready', async (req, res) => {
try {
await withTimeout(db.query('SELECT 1'), 500);
res.status(200).json({ status: 'healthy' });
} catch {
res.status(503).json({ status: 'unhealthy' });
}
});
Python/Django and Flask Services
Django doesn't ship a built-in health check view, but it's a few lines with the ORM:
from django.http import JsonResponse
from django.db import connections
from django.db.utils import OperationalError
def health_check(request):
checks = {}
healthy = True
try:
connections['default'].cursor()
checks['database'] = {'status': 'healthy'}
except OperationalError:
checks['database'] = {'status': 'unhealthy'}
healthy = False
status_code = 200 if healthy else 503
return JsonResponse({'status': 'healthy' if healthy else 'unhealthy', 'checks': checks}, status=status_code)
Flask is nearly identical, using a raw connection check or db.session.execute('SELECT 1') if you're on SQLAlchemy. For both frameworks, avoid running your health check through the full middleware stack (session handling, CSRF checks, auth middleware) since that adds latency and complexity to something that should be a lightweight signal. Exclude the health route from unnecessary middleware where your framework allows it.
Containerized Services and Kubernetes
Kubernetes has native support for liveness and readiness probes, and the distinction from earlier matters directly here:
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
Set failureThreshold above 1 for readiness probes so a single slow response doesn't yank a pod out of rotation. Kubernetes will keep sending traffic to pods that pass readiness and will restart pods that repeatedly fail liveness, so getting these two checks wired to the correct endpoint types is one of the highest-leverage things you can do in a containerized setup.
Database Connectivity Verification Without Impacting Performance
The temptation is to run a real query against your primary tables to "really" verify the database works. Don't. Use SELECT 1 or your database driver's native ping/ping-equivalent. You're checking that a connection can be established and a round trip completes, not that your data is correct. Running expensive queries in a health check that gets polled every 15 seconds adds unnecessary load, and during an actual incident, it can make things worse by adding query pressure to an already struggling database.
If you use connection pooling, check that the pool can hand out a connection within your timeout window rather than opening a fresh connection each time, since that's a more accurate representation of what your app actually experiences.
Third-Party Service Status Integration
For dependencies like Stripe, SendGrid, or an auth provider, avoid making a live API call in your health check if you can help it. Live calls add latency, cost (some APIs bill per call), and introduce failure modes outside your control. Instead, consider tracking the success/failure rate of real requests to that service over a short rolling window (the last 60 seconds, for example) and reporting degraded status if the failure rate crosses a threshold. This gives you a more honest signal without hammering a third party's API every 10 seconds from every instance you run.
Common Health Check Endpoint Mistakes and How to Avoid Them
Too verbose or too minimal. A health check that returns nothing but 200 OK gives you no diagnostic value when something goes wrong. A health check that returns full stack traces, environment variables, and internal IP addresses is a security liability. Aim for structured, dependency-level status without leaking implementation details.
Failing to monitor critical dependencies. If your app cannot function without Redis but your health check doesn't check Redis, you'll get pages from customers before you get pages from your monitoring. Map out what your service actually needs to do its job and make sure each of those is represented, but only those.
Not accounting for cascading failures. If service A's health check pings service B, and service B's health check pings service C, a failure in C can cascade into every service reporting unhealthy, which can trigger orchestration systems to restart everything simultaneously. Be deliberate about which checks are "hard" dependencies (should fail the health check) versus "soft" ones (should be reported but not fail the check).
Ignoring performance impact. Every health check consumes resources: a database connection, a CPU cycle, a network round trip. If you're polling every 5 seconds from multiple monitoring sources and running deep checks each time, that adds up. Cache the result of expensive checks for a few seconds if your polling interval is aggressive, so you're not literally re-querying the database on every single poll.
Documentation and discoverability issues. New engineers should be able to find your health check endpoints without asking in Slack. Document them in your README or internal wiki: what each endpoint checks, what status codes mean, and which systems consume it (load balancer, Kubernetes, external monitor). This matters more as your team grows past two or three engineers.
Version control and changelog tracking. When you add or remove a dependency check, that's a change worth tracking, ideally in your service's changelog or commit history with a clear message. If an uptime monitor suddenly starts firing alerts after a deploy, "we added a new dependency check to /health/ready" should be an easy thing to check against recent changes. This kind of traceability also helps a lot when you're writing a postmortem after an incident and need to reconstruct exactly what changed and when.
Best Practices for Small Teams Managing Health Checks
Standardize across services. If you run more than one service, use the same endpoint paths, the same response format, and the same status code conventions everywhere. This lets you build one dashboard, one alerting rule set, and one runbook instead of maintaining separate logic per service. Even a two-person team benefits from this the moment they have a second service in production.
Integrate with on-call alerting. A health check endpoint that nobody's monitoring is just a diagnostic tool. Connect it to your actual alerting pipeline. If you're using webhook-based alerting to route incidents to Slack, PagerDuty, or a custom on-call rotation, health check failures should flow into that same pipeline rather than existing in a separate silo. Consistent alerting paths mean fewer missed pages and faster response.
Set appropriate polling intervals. Polling too frequently wastes resources and can trigger false positives from transient blips. Polling too infrequently delays detection. For most small teams, 30-60 second intervals for external uptime monitoring and 10-15 second intervals for internal orchestration probes strike a reasonable balance. Adjust based on how critical the service is and how much noise you can tolerate.
Use health checks to improve MTTR. The value of a deep health check with per-dependency status is that when something breaks, you already know where to look before you even open a terminal. Instead of guessing whether it's the database, the cache, or a third-party API, your health check response tells you immediately. This alone can cut meaningful time off your mean time to recovery, especially for small teams without a dedicated on-call engineer watching dashboards full time.
Scale your strategy as your team grows. What works for a single Rails app with one database won't work once you've split into eight microservices. Revisit your health check design at each major scaling milestone: when you add a new critical dependency, when you move to containers, when you add a second on-call engineer. It's a lot cheaper to evolve the pattern incrementally than to retrofit standardized health checks across a dozen services at once.
If you're building out your monitoring stack from scratch, pairing well-designed health check endpoints with an external uptime monitor and a public status page gives you both the internal diagnostic detail and the external transparency customers expect during incidents.
FAQ
What HTTP status code should a healthy endpoint return?
200 OK for healthy, 503 Service Unavailable for unhealthy. Avoid custom or unconventional status codes since most monitoring tools, load balancers, and orchestration systems are built around this convention. If you need a "degraded but functional" state, represent it in the response body rather than inventing a new status code.
How often should I poll a health check endpoint? For external uptime monitoring, 30-60 seconds is typical for most small teams, balancing quick detection against unnecessary load and noise. For internal Kubernetes probes, 10-15 seconds is common, though liveness probes can be slightly less frequent than readiness probes since restarts are a heavier action than removing a pod from rotation.
Should health checks include database queries?
Yes, but keep them minimal. A SELECT 1 or equivalent connection ping is enough to verify the database is reachable and responsive. Never run complex business-logic queries in a health check, since that adds load and latency without adding meaningful signal.
Can health check endpoints be a security risk? Yes, if they leak sensitive information like stack traces, internal hostnames, dependency versions, or infrastructure details. Keep public-facing health checks minimal (status and maybe per-dependency up/down state) and put detailed diagnostic information behind authentication or restrict it to internal network access.
What's the difference between health checks and synthetic monitoring? A health check tells you whether your service and its dependencies are technically functioning, usually queried directly against your own infrastructure. Synthetic monitoring simulates real user behavior, like logging in, adding an item to a cart, or completing a checkout flow, often from external locations, to verify the actual user experience works end to end. Health checks are faster and cheaper to run frequently; synthetic monitoring catches issues health checks miss, like a broken frontend build that never touches your backend dependencies at all. For a deeper comparison of the two approaches and when to use each, see this breakdown of synthetic monitoring versus real user monitoring.
Good health check endpoint design is one of those unglamorous engineering tasks that pays dividends every single day it's in production, quietly catching problems before they become customer-facing incidents. Spend the extra hour getting the shallow/deep split right, keep your status codes honest, and don't let scope creep turn your health check into a second API. Your on-call rotation will thank you the next time something breaks at 2 AM.