Express Middleware Health Check: Async Service Monitoring Guide
Learn to build an express middleware for async service monitoring. Validate database and external APIs to ensure your Node.js app stays reliable.
When your Node.js API starts, it’s rarely truly "ready" just because the event loop is running. I’ve spent too many hours debugging silent failures where an Express app reported a 200 OK status while its underlying MongoDB connection was dead or an external auth provider was timing out.
You need an express middleware that performs an active, async health check to verify these critical dependencies before you claim your service is alive.
The Problem: Why Simple Pings Fail
Most basic health check endpoints return a static JSON object: res.json({ status: 'ok' }). This is a "shallow" check. It tells you the process didn't crash, but it doesn't tell you if the application can actually do its job.
If your database pool is exhausted or a downstream microservice is returning 503s, a shallow check will lie to your load balancer, keeping a "zombie" instance in rotation.
Building a Robust Async Health Check
To get this right, you need to execute parallel asynchronous probes. I prefer using Promise.allSettled to avoid one failing check from masking others, ensuring we get a full report of our system's health.
Here is a practical implementation of an async service monitoring middleware:
JAVASCRIPT// healthCheckMiddleware.js const mongoose = require(CE9178">'mongoose'); const healthCheck = async (req, res) => { const checks = [ { name: CE9178">'database', check: () => mongoose.connection.readyState === 1 }, { name: CE9178">'auth-service', check: () => fetch(CE9178">'https://auth.api.internal/health').then(r => r.ok) } ]; const results = await Promise.allSettled(checks.map(c => c.check())); const healthReport = results.map((result, i) => ({ service: checks[i].name, status: result.status === CE9178">'fulfilled' && result.value ? CE9178">'up' : CE9178">'down' })); const isHealthy = healthReport.every(r => r.status === CE9178">'up'); res.status(isHealthy ? 200 : 503).json({ status: isHealthy ? CE9178">'ok' : CE9178">'degraded', details: healthReport }); }; module.exports = healthCheck;
Why This Architecture Matters
- Parallelism: By using
Promise.allSettled, we don't wait for the database to timeout before checking the API. This is crucial for keeping your node.js health check response time under about 200-300ms. - Explicit States: Returning a 503 (Service Unavailable) when one dependency fails is standard practice. It tells your orchestrator (like Kubernetes or an AWS ALB) to pull that node out of the rotation.
- Async Error Handling: If you're struggling with unhandled promise rejections, make sure you've mastered Node.js Async Error Handling in Express: A Practical Guide. If your health check itself crashes, it's useless.
Common Pitfalls
We first tried wrapping these checks in standard try/catch blocks inside the route. It became a mess of nested callbacks. Moving to a centralized function that returns a structured report made our logs significantly cleaner.
Also, be careful with "heavy" health checks. If your check runs a complex SELECT count(*) query on your primary table, you're effectively DDOSing your own database every time the load balancer pings you. Stick to connection-level checks or lightweight metadata endpoints.
Quick Comparison of Health Check Strategies
| Strategy | Performance | Accuracy | Best For |
|---|---|---|---|
| Process Ping | Extremely Fast | Low | Basic liveness checks |
| Async Probes | Moderate | High | Production service readiness |
| Full Integration | Slow | Very High | Startup/Init scripts |
Improving Your Setup
If your services are prone to transient issues, don't just mark them as 'down' immediately. Implement a retry pattern to handle those momentary hiccups. Check out my guide on Node.js Retry Pattern: Handling Transient Failures with Express to see how to wrap these calls in exponential backoff.
One thing I'm still experimenting with is integrating Node.js Promise.allSettled: Handling Concurrent Async Tasks in Express more deeply into our monitoring dashboard. It’s great for seeing exactly which part of the chain is failing without digging through stack traces.
FAQ
Q: Should I run health checks on every request?
No. Health checks are for infrastructure orchestrators. Running them on every request adds unnecessary latency. Use a dedicated path like /health and point your load balancer there.
Q: What if the database is "up" but slow? A simple connection check won't catch latency. If you need to monitor performance, add a timeout to your check. If it takes longer than 500ms, consider it "down" for the purpose of the health check.
Q: Does this handle socket hangs? Not by itself. You'll want to ensure you've handled Express.js Async Timeout: Prevent Middleware Hangs and Socket Stalls so that a hanging check doesn't block your event loop.
Building a resilient system is all about knowing when things are broken before your users do. By implementing proper express error handling and proactive monitoring, you're already ahead of the game.
