Framework Guide

Express API Monitoring

Set up health check endpoints and uptime monitoring for your Express.js application. Production-ready patterns for API monitoring.

Why Monitor Your Express App?

Express.js is the most popular Node.js web framework, powering millions of APIs in production. But its minimalist design means there is no built-in health monitoring -- it is up to you to detect when your app silently stops working. External monitoring catches failures that process managers and internal logging miss.

  • Unhandled exceptions -- A single uncaught error can crash the Express process, leaving users with connection refused errors until PM2 restarts it
  • Middleware chain failures -- A broken middleware can silently swallow requests without sending a response, causing timeouts
  • Memory leaks -- Express apps often accumulate memory through closures, event listeners, or cached data until the process is killed
  • Dependency outages -- Database, Redis, or external API failures cause cascading 500 errors across your routes

Simple Health Endpoint

const express = require('express');
const app = express();

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString()
  });
});

Comprehensive Health Check

const express = require('express');
const app = express();

app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

app.get('/ready', async (req, res) => {
  const checks = {};
  let healthy = true;

  // Check database
  try {
    const start = Date.now();
    await db.query('SELECT 1');
    checks.database = {
      status: 'ok',
      responseTimeMs: Date.now() - start
    };
  } catch (error) {
    healthy = false;
    checks.database = {
      status: 'error',
      message: error.message
    };
  }

  // Check Redis
  try {
    const start = Date.now();
    await redis.ping();
    checks.redis = {
      status: 'ok',
      responseTimeMs: Date.now() - start
    };
  } catch (error) {
    healthy = false;
    checks.redis = {
      status: 'error',
      message: error.message
    };
  }

  const statusCode = healthy ? 200 : 503;
  res.status(statusCode).json({
    status: healthy ? 'healthy' : 'unhealthy',
    timestamp: new Date().toISOString(),
    checks,
    uptime: process.uptime(),
    memory: process.memoryUsage()
  });
});

Health Check Router

// routes/health.js
const express = require('express');
const router = express.Router();

// Liveness probe
router.get('/live', (req, res) => {
  res.json({ status: 'alive' });
});

// Readiness probe
router.get('/ready', async (req, res) => {
  try {
    await checkDependencies();
    res.json({ status: 'ready' });
  } catch (error) {
    res.status(503).json({
      status: 'not ready',
      error: error.message
    });
  }
});

// Detailed health
router.get('/detailed', async (req, res) => {
  const health = await getDetailedHealth();
  res.status(health.healthy ? 200 : 503).json(health);
});

module.exports = router;

// app.js
const healthRouter = require('./routes/health');
app.use('/health', healthRouter);

Using express-healthcheck

const healthcheck = require('express-healthcheck');

app.use('/health', healthcheck({
  healthy: function () {
    return { everything: 'is ok' };
  }
}));

// Custom checks
app.use('/health', healthcheck({
  test: async function () {
    // Throw error if unhealthy
    await db.query('SELECT 1');
    await redis.ping();
  }
}));

Add Timeouts

async function checkWithTimeout(fn, timeoutMs = 5000) {
  return Promise.race([
    fn(),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('timeout')), timeoutMs)
    )
  ]);
}

app.get('/ready', async (req, res) => {
  try {
    await checkWithTimeout(() => db.query('SELECT 1'), 5000);
    await checkWithTimeout(() => redis.ping(), 2000);

    res.json({ status: 'ready' });
  } catch (error) {
    res.status(503).json({
      status: 'not ready',
      error: error.message
    });
  }
});

Skip Logging for Health Checks

const morgan = require('morgan');

// Skip health check logging
app.use(morgan('combined', {
  skip: (req, res) => req.path === '/health' || req.path === '/ready'
}));

Best Practices

  • Separate liveness and readiness — /health/live and /health/ready serve different purposes
  • Add timeouts — Don't let slow dependencies hang the health check
  • Skip logging — Avoid cluttering logs with health check traffic
  • Include process info — Uptime, memory usage, and version help debugging

Common Express Production Issues

Unhandled Promise Rejections

Async route handlers that throw without a try/catch leave the request hanging until the client times out. Express 5 fixes this, but in Express 4, wrap async handlers or use express-async-errors. Health checks detect when your app stops responding due to leaked promises.

Event Loop Blocking

CPU-intensive operations (JSON parsing large payloads, image processing, bcrypt hashing) block the Node.js event loop, freezing all requests. Your Express app appears "up" but responds to nothing. Health checks with timeouts catch this -- if the health endpoint doesn't respond in 5 seconds, something is blocking the loop.

Connection Pool Exhaustion

Database and Redis connection pools have limited capacity. Under high load, all connections become occupied and new queries hang indefinitely. Include a database ping with a timeout in your readiness check to detect pool exhaustion before it affects users.

Graceful Shutdown Failures

Without proper SIGTERM handling, deploying a new version kills in-flight requests. During the restart gap, monitoring detects the brief downtime. Implement graceful shutdown with server.close() and drain existing connections before exiting. PM2 and Docker handle this if configured correctly.

Frequently Asked Questions

What should an Express.js health check endpoint return?
An Express health check should return HTTP 200 with a JSON body containing a status field. Use res.json({ status: 'healthy' }) for a simple liveness check. For readiness checks, verify database connectivity, Redis, and external APIs, then return 200 if all pass or 503 if any critical dependency is down.
Should I use a separate Express Router for health checks?
Yes, using express.Router() for health checks keeps them organized and makes it easy to mount them at a specific path prefix like /health. This also lets you exclude health routes from authentication middleware, rate limiting, and logging by mounting the health router before those middleware layers.
How do I add timeouts to dependency checks?
Wrap each dependency check in a Promise.race() with a timeout promise. For example: Promise.race([db.query('SELECT 1'), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]). This prevents a slow database from hanging your entire health check endpoint.
How do I prevent health checks from cluttering Express logs?
If using morgan for logging, pass a skip function: morgan('combined', { skip: (req) => req.path === '/health' }). This filters out health check requests that fire every few minutes. For custom loggers, check the request path before logging. This keeps your logs focused on real user traffic.
How often should I monitor my Express API?
For production Express APIs, check every 1-5 minutes. UptimeSignal's free tier checks every 5 minutes, which catches most outages quickly. Pro tier supports 1-minute intervals for mission-critical services. Express apps running on PM2 or Docker auto-restart on crashes, but external monitoring detects the brief downtime gap.

Monitor your Express API

Add your health endpoint to UptimeSignal and get alerted when it fails. Free tier includes 25 monitors.

Start monitoring free →

No credit card required. Commercial use allowed.

More Framework Guides