A process receiving SIGTERM is not the same as a process being ready to exit. It may still be writing a response, committing a transaction, acknowledging a queue message, or holding a connection that another system believes is healthy.
Graceful shutdown is the short protocol between “stop sending new work” and “the process is gone.” This tutorial builds that protocol into a tiny Node.js service and tests the behavior from a shell.
Define the shutdown contract
The example service has two endpoints:
-
GET /healthzreports whether the instance should receive new traffic. -
POST /workrepresents a five-second in-flight operation.
On SIGTERM, the process must:
- change readiness to
503immediately; - reject new work;
- leave a short drain window for a load balancer to observe readiness;
- stop accepting connections;
- let active requests finish;
- force exit if the grace deadline expires.
This ordering separates readiness from liveness. The process is alive while draining, but it should no longer be selected for new requests.
Build the service
Save this as server.mjs:
import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 3000);
const DRAIN_MS = Number(process.env.DRAIN_MS ?? 1500);
const GRACE_MS = Number(process.env.GRACE_MS ?? 10000);
let draining = false;
let activeRequests = 0;
let forceTimer;
const delay = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function sendJson(response, status, value) {
response.writeHead(status, {
"content-type": "application/json",
connection: draining ? "close" : "keep-alive",
});
response.end(`${JSON.stringify(value)}\n`);
}
const server = createServer(async (request, response) => {
if (request.url === "/healthz") {
sendJson(response, draining ? 503 : 200, {
ready: !draining,
activeRequests,
});
return;
}
if (request.url === "/work" && request.method === "POST") {
if (draining) {
sendJson(response, 503, { error: "server_draining" });
return;
}
activeRequests += 1;
const startedAt = Date.now();
try {
await delay(5000);
sendJson(response, 200, {
ok: true,
elapsedMs: Date.now() - startedAt,
});
} finally {
activeRequests -= 1;
console.log(
`request-finished active=${activeRequests} duration_ms=${Date.now() - startedAt}`,
);
}
return;
}
sendJson(response, 404, { error: "not_found" });
});
The request counter is deliberately visible in logs and the health response. It is not a substitute for tracing, but it makes the test outcome observable.
Add the shutdown handler:
function beginShutdown(signal) {
if (draining) return;
draining = true;
console.log(`shutdown-start signal=${signal} active=${activeRequests}`);
forceTimer = setTimeout(() => {
console.error(`shutdown-forced active=${activeRequests}`);
server.closeAllConnections?.();
process.exit(1);
}, GRACE_MS);
forceTimer.unref();
setTimeout(() => {
server.close((error) => {
clearTimeout(forceTimer);
if (error) console.error(error);
console.log(`shutdown-complete active=${activeRequests}`);
process.exit(error ? 1 : 0);
});
server.closeIdleConnections?.();
}, DRAIN_MS);
}
process.once("SIGTERM", () => beginShutdown("SIGTERM"));
process.once("SIGINT", () => beginShutdown("SIGINT"));
server.listen(PORT, () => {
console.log(`listening port=${PORT}`);
});
Node documents that server.close() stops new connections and closes connections that are not sending a request or waiting for a response. The callback runs after remaining connections close. The explicit force timer covers a handler or connection that does not finish before the application’s deadline.
Verify an active request survives
Run the complete test from one terminal so the shell keeps the exact server PID:
node server.mjs &
server_pid=$!
sleep 0.5
curl -sS -X POST http://127.0.0.1:3000/work &
sleep 0.25
kill -TERM "$server_pid"
sleep 0.1
curl -i http://127.0.0.1:3000/healthz
wait
The original /work request should still return a successful JSON response after about five seconds. The process log should have the same event order as:
listening port=3000
shutdown-start signal=SIGTERM active=1
request-finished active=0 duration_ms=5001
shutdown-complete active=0
The exact duration will vary. The properties that matter are readiness 503, a successful active request, and shutdown-complete after request-finished.
The code above was verified on macOS with Node.js v22.22.3 and curl 7.84.0 over loopback. The normal path returned readiness 503, completed the active request in roughly five seconds, and exited cleanly. The forced path below exited with status 1 and logged shutdown-forced active=1.
Test the forced-exit path
The normal test proves the request can finish. Also prove that shutdown is bounded. Temporarily make work longer than the grace period, or start with a one-second deadline:
GRACE_MS=1000 node server.mjs
Run /work, send SIGTERM, and expect this event before the five-second operation completes:
shutdown-forced active=1
The non-zero exit matters. It distinguishes an incomplete shutdown from a clean one in process supervisors and deployment logs.
Map the contract to an orchestrator
Kubernetes documents its Pod termination flow: endpoint removal, optional preStop, the TERM signal, and eventual forced termination all consume the Pod’s grace period.
That creates three timing constraints:
drain delay + maximum request time < application grace deadline
application grace deadline < orchestrator termination grace period
readiness propagation time < drain delay
Do not copy the example’s 1.5-second drain delay into production without measurement. Observe how quickly the actual load balancer or service mesh stops routing. A fixed delay is a compatibility budget for that propagation, not a universal constant.
For queue consumers, replace HTTP readiness with “stop polling,” then finish or safely return the current message. For background jobs, checkpointing or idempotency may be more important than waiting. The same rule holds: stop acquiring work before waiting for owned work to settle.
Applying the contract to a self-hosted AI platform
The same shutdown contract matters when you operate a self-hosted AI development platform. A task may still be streaming output, changing files, running a build, or waiting for a model response when a worker receives SIGTERM. Treating that worker as disposable without a drain phase can leave the user with an ambiguous task state.
MonkeyCode is one open-source option for teams that want managed development environments and private deployment. If you are evaluating it, use the test pattern above as an operations checklist: identify which component stops accepting tasks, which work is allowed to finish, how incomplete tasks are reported, and which deadline wins when shutdown is forced. Those are questions to verify against your own deployment rather than capabilities this small Node.js example proves about MonkeyCode.
Disclosure: I contribute to the MonkeyCode project.
You can join the official MonkeyCode Discord to discuss deployment with the team. If you are interested in trying the hosted service, contact the team there to ask about currently available free model credits and their eligibility or usage limits.
Production checks
Before relying on graceful shutdown, verify:
- every shutdown signal reaches the application process;
- the readiness check changes before the listener closes;
- new work is rejected during draining;
- active requests, database transactions, and queue acknowledgements have defined deadlines;
- keep-alive, WebSocket, and streaming connections have an explicit policy;
- the application deadline fits inside the platform’s grace period;
- forced shutdown is logged and alerted separately from clean shutdown;
- repeated signals do not start competing shutdown sequences.
Graceful shutdown is successful only when its failure paths are observable. Test it with active work, test the deadline, and record which component owns every millisecond between SIGTERM and process exit.
Top comments (0)