App Deployment·8 min read·

Node Health Checks That Fail Fast When Wedged

Expose a cheap health endpoint, let systemd watchdog the event loop, and fail the unit before a wedged Node process sits silent in production.

NB

Netbay Developer Relations

Netbay Engineering

On this page

A wedged Node process still has a pid. systemd thinks it is active. nginx still proxy_passes to it. Clients time out. The failure mode is silence, which is worse than a crash, because a crash restarts and silence sits there until a human notices. Health checks exist to turn silence into a failed unit. This post wires three layers on Ubuntu: a cheap HTTP /health, a systemd watchdog that expects a notify ping, and a boot path that exits non-zero if a required dependency is missing.

Failing fast means the unit never reports ready when it cannot serve. It does not mean the health route runs a five-table join.

/health is a liveness probe, not a dashboard

The route must run on the same event loop as the rest of the app. If the loop is blocked, the route does not answer and the checker fails. That is the feature. Keep the handler tiny: return 200 and a short body when the process can accept work. Return 503 when a required dependency is known-down. Do not return 200 because the process exists.

javascript
const http = require('http');
const net = require('net');
let ready = false;
let dbOk = true;
function pingDb(cb) {
  const sock = net.connect({ host: '127.0.0.1', port: 5432 });
  const t = setTimeout(function () { sock.destroy(); cb(false); }, 400);
  sock.on('connect', function () { clearTimeout(t); sock.end(); cb(true); });
  sock.on('error', function () { clearTimeout(t); cb(false); });
}
const server = http.createServer(function (req, res) {
  if (req.url === '/health') {
    const ok = ready && dbOk;
    res.writeHead(ok ? 200 : 503, { 'Content-Type': 'text/plain' });
    res.end(ok ? 'ok\n' : 'fail\n');
    return;
  }
  res.end('app\n');
});
server.listen(3000, '127.0.0.1', function () {
  pingDb(function (ok) {
    if (!ok) {
      console.error('database unreachable at boot');
      process.exit(1);
    }
    dbOk = true;
    ready = true;
  });
});
setInterval(function () {
  pingDb(function (ok) { dbOk = ok; });
}, 5000).unref();

The boot path exits 1 if Postgres is down. systemd restarts with Restart=on-failure, and you get a failed unit after StartLimitBurst instead of an app that accepts HTTP and 500s every query. The interval ping updates dbOk so /health can go 503 without exiting; a blip should not SIGKILL the process. A missing database at start is a config error. A missing database at minute 40 might be a maintenance window. Those are different.

nginx can proxy /health to the same upstream or you can curl loopback from a timer. Do not expose /health with stack traces or config dumps. L3/L4 DDoS filtering will not stop someone from scraping an open debug route.

WatchdogSec catches a frozen event loop

Type=notify plus WatchdogSec asks the process to ping systemd on an interval. If the ping stops, systemd treats the unit as failed and kills it. A busy CPU or a blocked loop cannot JSON.stringify its way out; it simply misses the deadline. That is the check /health cannot run when the loop is stuck, because /health is on the same loop.

javascript
const fs = require('fs');
const path = process.env.NOTIFY_SOCKET;
function sdNotify(msg) {
  if (!path) return;
  const sockPath = path[0] === '@' ? '\0' + path.slice(1) : path;
  const client = require('dgram').createSocket('unix_dgram');
  const buf = Buffer.from(msg);
  client.send(buf, 0, buf.length, sockPath, function () { client.close(); });
}
sdNotify('READY=1\n');
const wdUsec = Number(process.env.WATCHDOG_USEC || 0);
if (wdUsec > 0) {
  const every = Math.max(1000, Math.floor(wdUsec / 2000));
  setInterval(function () { sdNotify('WATCHDOG=1\n'); }, every).unref();
}

WATCHDOG_USEC is microseconds. Ping at half that. READY=1 is what Type=notify waits for before it considers the service started. If you never send READY, systemd hits TimeoutStartSec and kills you. That is the correct outcome for an app that hangs during listen().

ini
[Service]
Type=notify
NotifyAccess=main
WatchdogSec=30
TimeoutStartSec=20
TimeoutStopSec=20
ExecStart=/usr/bin/node /srv/app/server.js
ExecStartPost=/usr/bin/curl -fsS --max-time 3 http://127.0.0.1:3000/health
Restart=on-failure
RestartSec=2
StartLimitBurst=5
StartLimitIntervalSec=60

ExecStartPost runs after READY. curl -fsS fails the start if /health is not 200. WatchdogSec=30 is long enough for GC on a 2 GB box and short enough that a wedged loop is gone in under a minute. StartLimitBurst stops a crash loop from becoming a CPU spin.

What not to check

Do not call an upstream payment API from /health. Do not compile templates. Do not allocate a heap snapshot. A probe that is expensive becomes the outage. Ready versus live: live means the process should stay up, ready means nginx may send it traffic. systemd watchdog is liveness. /health can encode readiness with 503. Mixing them into one slow function is how you page yourself with false positives.

When the unit fails, journalctl -u nodeapp.service -b tells you whether it was a boot ping, a watchdog, or an exit(1). Fix that cause. Do not raise WatchdogSec to 5 minutes to hide a leak.

Three ways a wedged Node process gets killed boot ping exit 1 if DB missing GET /health 200 or 503, cheap WatchdogSec READY + WATCHDOG frozen event loop missed ping, SIGABRT silence becomes a failed unit, not a hidden timeout Lucknow VPS, Xeon Platinum, fail fast then restart

The takeaway: crash at boot if you cannot serve, answer /health cheaply, and let systemd abort a process that can no longer ping. A pid is not a healthy app.

Add the watchdog on a Netbay Ubuntu 24.04 VPS and wedge the loop on purpose once, so you trust the restart — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance