Express apps need a way to signal their operational status.

Here’s a simple Express app:

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

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Let’s add a health check endpoint. This endpoint will tell us if the application is running and able to respond.

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

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.get('/health', (req, res) => {
  res.status(200).send('OK');
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Now, if you curl http://localhost:3000/health, you’ll get OK. This is a basic health check.

However, "health" is often more than just "is the process running?" It usually implies that the application is ready to serve traffic. This is where readiness checks come in. A readiness check verifies that the application has completed its initialization and is fully functional.

Consider an app that needs to connect to a database or load configuration files on startup. It might be "healthy" (the process is alive) but not yet "ready" to accept requests.

Let’s simulate a startup process. We’ll use a flag to indicate readiness and a timeout to simulate a slow initialization.

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

let isReady = false;

// Simulate database connection or other startup tasks
setTimeout(() => {
  console.log('Application initialized and ready!');
  isReady = true;
}, 5000); // Takes 5 seconds to become ready

app.get('/', (req, res) => {
  if (!isReady) {
    return res.status(503).send('Service Unavailable');
  }
  res.send('Hello World!');
});

app.get('/health', (req, res) => {
  // Health check: Is the process running?
  res.status(200).send('OK');
});

app.get('/ready', (req, res) => {
  // Readiness check: Is the application ready to serve traffic?
  if (isReady) {
    res.status(200).send('Ready');
  } else {
    res.status(503).send('Not Ready');
  }
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

In this refined example:

  • /health (HTTP 200) means the process is up and running.
  • /ready (HTTP 200) means the application has finished its startup tasks and can handle requests. If it returns HTTP 503, it indicates it’s still initializing.
  • The root route / also checks isReady and returns a 503 if not ready.

This distinction is crucial for orchestrators like Kubernetes. Kubernetes uses readiness probes to determine when an application is ready to receive traffic. If a readiness probe fails, Kubernetes stops sending traffic to that pod until the probe succeeds. Health probes, on the other hand, are typically used to decide whether to restart a pod.

When designing these endpoints, consider what constitutes "ready" for your application. This could involve:

  • Successful database connections.
  • Availability of external services (e.g., message queues, other APIs).
  • Completion of critical configuration loading.
  • Successful health checks of internal components.

For a more robust health check, you might want to check dependencies. For example, if your application relies on a database, your health check could actually query the database to ensure it’s responsive.

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

// Assume you have a db connection object, e.g., 'db'
// const db = require('./db'); // In a real app

let isReady = false;

// Simulate startup tasks
setTimeout(() => {
  console.log('Application initialized and ready!');
  isReady = true;
}, 5000);

// Mock database ping function
async function pingDatabase() {
  // In a real app: await db.ping();
  return new Promise(resolve => setTimeout(() => resolve(true), 100)); // Simulate a quick DB ping
}

app.get('/', (req, res) => {
  if (!isReady) {
    return res.status(503).send('Service Unavailable');
  }
  res.send('Hello World!');
});

app.get('/health', async (req, res) => {
  // Basic health check: process is running.
  // Optionally, add a quick check of critical dependencies.
  try {
    // Example: Ping the database if it's a critical dependency
    // await pingDatabase();
    res.status(200).send('OK');
  } catch (error) {
    console.error('Health check failed:', error);
    res.status(503).send('Service Unavailable');
  }
});

app.get('/ready', async (req, res) => {
  // Readiness check: Application is fully initialized and dependencies are available.
  if (!isReady) {
    return res.status(503).send('Not Ready - Initializing');
  }
  try {
    await pingDatabase(); // Ensure database is responsive
    res.status(200).send('Ready');
  } catch (error) {
    console.error('Readiness check failed:', error);
    res.status(503).send('Not Ready - Dependency unavailable');
  }
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

The /health endpoint is now more robust by attempting a pingDatabase. If this ping fails, the health check returns a 503. The /ready endpoint also pings the database, ensuring that not only is the app initialized (isReady flag), but its critical dependencies are also functioning.

When setting up these endpoints in an orchestrator like Kubernetes, you’d configure separate livenessProbe (for /health) and readinessProbe (for /ready) definitions, specifying httpGet actions with the respective paths, ports, and initial delays.

Want structured learning?

Take the full Express course →