01
1. Separate accepting work from completing it
Choose two test cases: a request that finishes after 300 milliseconds and one that never finishes. A shutdown implementation should allow the first to complete, while the second must not hold the process open indefinitely.
The example uses Node.js 24 with built-in modules and a loopback listener on an automatically chosen port. It has no npm dependencies. Save it as graceful-server.mjs.
02
2. Add the shutdown protocol
The handler records that draining has begun, calls server.close and arms a one-second deadline. Repeated signals return without creating more timers. During draining, requests that reach the handler are rejected instead of starting new work.
The /hold route is deliberately broken to test the deadline. Do not copy that route into an actual API. The forced branch exits unsuccessfully because it abandoned an incomplete connection.
import http from 'node:http';
let stopping = false;
const server = http.createServer((req, res) => {
if (stopping) {
res.writeHead(503, { Connection: 'close' });
res.end('draining');
return;
}
if (req.url === '/hold') {
res.writeHead(200);
res.flushHeaders();
console.log('request-started');
return; // Deliberately never completes: use only in this lab.
}
console.log('request-started');
setTimeout(() => res.end('done'), 300);
});
function shutdown() {
if (stopping) return;
stopping = true;
const deadline = setTimeout(() => {
console.error('shutdown-deadline-exceeded');
server.closeAllConnections();
process.exit(1);
}, 1000);
server.close((error) => {
clearTimeout(deadline);
process.exitCode = error ? 1 : 0;
});
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
server.listen(0, '127.0.0.1', () => {
console.log(JSON.stringify({ pid: process.pid, port: server.address().port }));
});03
3. Verify the graceful path
Start the file with node graceful-server.mjs and use the printed port for a request. The startup JSON also identifies the process ID; use that PID when sending the signal from another terminal. Begin a request, wait until the server prints request-started, then send SIGTERM to that specific Node process.
The response should still finish with done and the process should exit with code zero. Start the request before signaling; requesting an already closed listener only tests connection refusal, not draining.
04
4. Verify the deadline path
Start a fresh process and request /hold. Wait for request-started, then signal that process. The response will remain incomplete until the shutdown deadline closes the connection and exits with code one.
A real test must check both the client outcome and the child process’s exit code. A log saying “shutting down” is not proof that either request completed or the process actually stopped.
05
5. Fit the deadline into your runtime
Set the application’s drain budget below the container or service manager’s termination budget so the forced branch can run before an external kill. Remove readiness before the final stop when your routing environment supports that sequence.
Close owned database pools and consumers after their in-flight work is settled. WebSocket and other upgraded sockets are outside this HTTP example; server.closeAllConnections is not a universal cleanup function for every resource in the process.
06
Verification boundary
The editorial Node.js 24 test launched two real child processes. One completed its in-flight HTTP response and exited zero after SIGTERM. The deliberate stuck response was disconnected by the deadline and the second child exited one. No application or container from the existing workspace was signaled.
Know the limits
This Node.js 24 example covers an HTTP/1 server. Database pools, queue consumers and upgraded connections require their own shutdown design.
Sources & further reading
A technical publication by CoreTrailKit.
Browse all articles