Node.js 80 🟢 Signal Handling (SIGTERM, SIGINT) and Graceful Shutdown Patterns
A Node.js process that ignores SIGTERM is a process that Docker kills after ten seconds. An orchestrator sends the signal, waits for the configured grace period, and then sends SIGKILL—which cannot be caught, cannot be handled, and gives the application no opportunity to finish what it was doing. The difference between a clean shutdown and a forced kill is not a matter of luck; it is a matter of whether the application registered a handler for the signal and whether that handler actually drains the resources it is responsible for.
The two signals that matter in production are SIGTERM and SIGINT. SIGTERM is the signal that Docker, Kubernetes, and systemd send when they want the process to stop. It is polite: the process can catch it, perform cleanup, and exit on its own terms . SIGINT is the signal sent when a user presses Ctrl+C in a terminal. It is also catchable, and in development it is the signal you want to handle for the same reason: to close the server and the database connection instead of leaving them in an inconsistent state . The third signal worth knowing is SIGHUP, which is often used to tell a daemon to reload its configuration, but in the context of shutdown patterns it is less critical than the other two.
Key point: Handle SIGTERM and SIGINT with a re-entrancy guard, call server.close() followed immediately by server.closeIdleConnections(), close database and cache connections, and set a hard-deadline timer with .unref() that forces exit before the orchestrator’s grace period expires; without closeIdleConnections(), the shutdown will hang on idle keep-alive connections until the timeout kills it.
Why the naive shutdown pattern is broken
The server.close() misconception. The most common graceful shutdown pattern is a handler that calls server.close() and then process.exit(0) in the callback. This looks correct and is almost always wrong. server.close() stops accepting new connections, but it does not close existing connections—including idle HTTP/1.1 keep-alive connections that have no pending request . If a browser or a load balancer holds an idle connection open, the close() callback never fires, the handler waits, and the process is killed by SIGKILL after the grace period. The fix is server.closeIdleConnections(), which was added in Node.js 18.2 and immediately releases those idle sockets without touching in-flight requests .
The multiple-signal problem. Docker and orchestrators may send SIGTERM more than once. If the handler is not guarded, the second signal starts a second shutdown sequence while the first is still running, which can close resources twice or call process.exit() in the middle of cleanup. The fix is a boolean flag at the top of the handler: if (shuttingDown) return; shuttingDown = true; .
The missing deadline problem. Even with closeIdleConnections(), a stuck in-flight request or a database connection that refuses to close can prevent the handler from completing. The orchestrator’s grace period is finite—ten seconds for Docker by default, thirty seconds for Kubernetes unless configured otherwise. The handler must set its own deadline that fires before the orchestrator’s SIGKILL. The pattern is setTimeout(() => { server.closeAllConnections(); process.exit(1); }, 8000).unref() . The .unref() call is critical: without it, the timer keeps the event loop alive even after all connections have drained and the process would otherwise exit naturally .
The signal-forwarding problem in containers. If the Dockerfile uses the shell form of CMD (CMD node server.js), the process runs as a child of /bin/sh, and the shell does not forward SIGTERM to its child. The Node.js process never sees the signal, and the container is killed after the timeout . The fix is the exec form: CMD ["node", "server.js"]. If a wrapper script is unavoidable, the script must use exec or a trap to forward signals .
a. The shutdown sequence in detail
The sequence has a specific order, and the order matters. The first step is the guard and the deadline. The second is stopping new connections. The third is draining idle connections. The fourth is closing resources. The fifth is exiting.
const http = require('node:http');
const server = http.createServer(app);
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}, starting graceful shutdown`);
// Hard deadline: force exit before the orchestrator's SIGKILL
const forceExit = setTimeout(() => {
console.error('Forced shutdown after timeout');
server.closeAllConnections();
process.exit(1);
}, 8000).unref(); // unref so it doesn't keep the loop alive
server.close(() => {
clearTimeout(forceExit);
console.log('HTTP server closed');
closeResources().then(() => {
console.log('Cleanup complete');
process.exit(0);
});
});
// Release idle keep-alive connections immediately
server.closeIdleConnections();
}
async function closeResources() {
await db.end();
await redis.quit();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
The server.close() callback fires when the last connection closes. The closeIdleConnections() call is placed after server.close() because it releases the idle sockets that would otherwise prevent the callback from firing. The forceExit timer is .unref()‘d so it does not keep the event loop alive on the happy path. The clearTimeout(forceExit) in the callback ensures the timer does not fire after the server has already shut down .
The closeResources function is where database connections, Redis clients, message queue connections, and any other resource with a close() or quit() method are closed. The order is generally: HTTP server first, then the resources that serve HTTP requests, then the resources that back those services. A database connection pool should be closed after the HTTP server has stopped accepting requests, because in-flight requests may still be using the pool .
b. The container and orchestrator context
The application’s shutdown handler does not operate in isolation. It operates inside a container that is being stopped by an orchestrator, and the orchestrator’s behavior determines the deadline the handler must beat.
Docker’s stop command sends SIGTERM to PID 1, waits for the stop timeout (default 10 seconds), and then sends SIGKILL . The timeout can be configured in docker-compose.yml with stop_grace_period: 30s or on the command line with docker stop -t 30. The application’s internal deadline must be shorter than the orchestrator’s grace period, leaving a margin for the cleanup to complete. The common pattern is an 8-second internal deadline against a 10-second Docker default .
Kubernetes sends SIGTERM when a pod is terminated, and it waits for terminationGracePeriodSeconds (default 30 seconds) before sending SIGKILL. The pod is also removed from the Service’s endpoints at the same time the signal is sent, but the load balancer and the kube-proxy take time to update their rules. This means the pod may receive new traffic for a few seconds after the signal. The shutdown handler should keep the health check endpoint returning 200 for a brief period (or return 503 only after a short delay) so that the load balancer stops sending traffic before the server stops accepting connections . A simpler approach is to rely on the fact that server.close() stops accepting new connections immediately, and the in-flight requests complete within the grace period.
The PID 1 problem is the other container-specific concern. If the application spawns child processes or uses a shell entrypoint, the Node.js process may not be PID 1, and signals may not reach it. The exec form of CMD makes Node.js PID 1 and the direct recipient of SIGTERM . If a wrapper script is necessary, tini or dumb-init can be used as the entrypoint to forward signals and reap zombie processes .
c. Testing the shutdown handler
A shutdown handler that has never been tested is a handler that will fail in production. The test does not need to be elaborate. It needs to verify that the server stops accepting connections, that in-flight requests complete, and that the process exits with the correct code.
The simplest test is a manual one: start the server, send a request that takes a few seconds to complete, send SIGTERM while the request is in flight, and verify that the request completes and the process exits cleanly. The curl command can be used to make the request, and kill -SIGTERM <pid> can be used to send the signal.
A more rigorous test uses a child process. The test spawns the server as a child process, makes a request, sends the signal, and asserts on the exit code and the response. The child process approach isolates the test from the test runner’s own process and allows the exit code to be verified directly. Libraries like lastcall provide a simulateSignal() method for testing shutdown without actually sending a signal to the process .
The key assertions are: the in-flight request receives a complete response, the process exits with code 0 on a clean shutdown, and the process exits with code 1 if the deadline is exceeded. The last assertion is tested by creating a request that hangs indefinitely and verifying that the force-exit timer fires and the process exits with the non-zero code.
Complete Example Session
The following session builds a server with a complete graceful shutdown handler, a resource that must be closed, and a test that verifies the handler works.
// ============================================
// PART 1: THE SERVER WITH GRACEFUL SHUTDOWN
// ============================================
const http = require('node:http');
const server = http.createServer(async (req, res) => {
if (req.url === '/slow') {
await new Promise(r => setTimeout(r, 3000));
res.end('slow response');
return;
}
res.end('ok');
});
// ============================================
// PART 2: THE RESOURCE TO CLOSE
// ============================================
const db = {
closed: false,
async end() {
await new Promise(r => setTimeout(r, 100));
this.closed = true;
console.log('DB connection closed');
},
};
// ============================================
// PART 3: THE SHUTDOWN HANDLER
// ============================================
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}, shutting down`);
const forceExit = setTimeout(() => {
console.error('Forced exit after timeout');
server.closeAllConnections();
process.exit(1);
}, 8000).unref();
server.close(() => {
clearTimeout(forceExit);
db.end().then(() => {
console.log('Shutdown complete');
process.exit(0);
});
});
server.closeIdleConnections();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.listen(3000, () => console.log('Server on :3000'));
# ============================================
# PART 4: TEST THE HAPPY PATH
# ============================================
# Start the server
node server.js &
# Make a slow request in the background
curl http://localhost:3000/slow &
# Send SIGTERM while the request is in flight
kill -SIGTERM <pid>
# Expected output:
# Received SIGTERM, shutting down
# (after 3 seconds) slow response
# DB connection closed
# Shutdown complete
# Exit code: 0
# ============================================
# PART 5: TEST THE TIMEOUT PATH
# ============================================
# Modify the /slow route to wait 15 seconds
# Make a request, then send SIGTERM
# Expected output:
# Received SIGTERM, shutting down
# Forced exit after timeout
# Exit code: 1
The five parts cover the server, the resource, the shutdown handler, the happy-path test, and the timeout test. What the session demonstrates is that the handler works because it drains the idle connections immediately, closes the server before the resources, and sets a deadline that is shorter than the orchestrator’s grace period.
Quick Reference
Signals
| Signal | Source | Catchable | Default Action |
|---|---|---|---|
SIGTERM | Docker, Kubernetes, systemd | ✅ | Terminate |
SIGINT | Ctrl+C | ✅ | Terminate |
SIGKILL | Orchestrator timeout | ❌ | Immediate kill |
Shutdown Sequence
| Step | Action |
|---|---|
| 1 | Guard: if (shuttingDown) return |
| 2 | Set force-exit timer with .unref() |
| 3 | server.close() |
| 4 | server.closeIdleConnections() |
| 5 | Close database, cache, queues |
| 6 | process.exit(0) |
Critical Details
| Detail | Why |
|---|---|
closeIdleConnections() | close() does not close idle keep-alive |
.unref() on timer | Timer must not keep loop alive on happy path |
Exec-form CMD | Shell form does not forward signals |
| Internal deadline < orchestrator grace | Leave margin before SIGKILL |
Common Methods
| Method | Purpose |
|---|---|
server.close() | Stop accepting new connections |
server.closeIdleConnections() | Release idle keep-alive |
server.closeAllConnections() | Force-close all (deadline only) |
process.exit(code) | Exit with code |
Best Practices
✅ Do This:
if (shuttingDown) return; // ✅ guard
setTimeout(() => process.exit(1), 8000).unref(); // ✅ deadline
server.close(); server.closeIdleConnections(); // ✅ drain idle
process.on('SIGTERM', () => shutdown('SIGTERM')); // ✅ handle
❌ Don’t Do This:
server.close(); process.exit(0); // ❌ no wait
setTimeout(() => process.exit(1), 8000); // ❌ no unref
// no handler at all // ❌ SIGKILL
CMD node server.js // ❌ no signal
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Shutdown hangs | Idle keep-alive connections | closeIdleConnections() |
| Process exits too early | Timer not .unref()‘d | .unref() |
| Double shutdown | No re-entrancy guard | if (shuttingDown) return |
| SIGTERM ignored in Docker | Shell-form CMD | Exec form ["node", ...] |
| Deadline exceeds grace | No internal timer | 8s internal vs 10s Docker |
Real-World Examples
1. Basic handler
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
});
2. Re-entrancy guard
let shuttingDown = false;
function shutdown() { if (shuttingDown) return; shuttingDown = true; }
3. Force-exit timer
setTimeout(() => { server.closeAllConnections(); process.exit(1); }, 8000).unref();
4. Close database
server.close(() => { db.end().then(() => process.exit(0)); });
5. Docker grace period
services:
api:
stop_grace_period: 30s
6. Exec-form CMD
CMD ["node", "server.js"]
7. tini as init
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
8. Health check during shutdown
app.get('/health', (req, res) => {
if (shuttingDown) return res.status(503).json({ status: 'shutting_down' });
res.json({ status: 'ok' });
});
9. Test with kill
kill -SIGTERM <pid>
10. Verify exit code
echo $? # 0 for clean, 1 for forced
Visual
Shutdown Timeline
┌──────────────────────────────────────────────┐
│ SIGTERM received │
│ │ │
│ ▼ │
│ Guard check (shuttingDown) │
│ │ │
│ ▼ │
│ Start 8s force-exit timer (.unref) │
│ │ │
│ ▼ │
│ server.close() │
│ │ │
│ ▼ │
│ server.closeIdleConnections() │
│ │ │
│ ▼ │
│ In-flight requests complete │
│ │ │
│ ▼ │
│ close() callback fires │
│ │ │
│ ▼ │
│ Clear timer, close DB, exit(0) │
└──────────────────────────────────────────────┘
close() vs closeIdleConnections()
┌──────────────────────────────────────────────┐
│ server.close() │
│ - stops accepting new connections │
│ - waits for existing connections │
│ - does NOT close idle keep-alive │
│ │
│ server.closeIdleConnections() │
│ - closes idle keep-alive connections │
│ - does NOT touch in-flight requests │
│ │
│ Without the second call, close() hangs │
│ on idle connections until the timeout. │
└──────────────────────────────────────────────┘
Container Signal Flow
┌──────────────────────────────────────────────┐
│ docker stop │
│ │ │
│ ▼ │
│ SIGTERM → PID 1 │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ exec form shell form │
│ │ │ │
│ ▼ ▼ │
│ Node /bin/sh │
│ receives does not forward │
│ signal to node │
│ │ │ │
│ ▼ ▼ │
│ Graceful 10s timeout │
│ shutdown → SIGKILL │
└──────────────────────────────────────────────┘
Force-Exit Timer
┌──────────────────────────────────────────────┐
│ setTimeout(() => { │
│ server.closeAllConnections(); │
│ process.exit(1); │
│ }, 8000).unref(); │
│ │
│ Without .unref(): │
│ Timer keeps loop alive → process hangs │
│ even after connections drain │
│ │
│ With .unref(): │
│ Timer does not prevent natural exit │
│ Only fires if shutdown is stuck │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Signals to handle | SIGTERM, SIGINT |
| Docker default grace | 10 seconds |
| Kubernetes default grace | 30 seconds |
| Internal deadline | 8 seconds |
| Critical method | server.closeIdleConnections() |
| Force-exit method | server.closeAllConnections() |
| Timer requirement | .unref() |
| Guard requirement | if (shuttingDown) return |
| Container CMD | Exec form |
Key takeaways:
server.close()does not close idle keep-alive connections. It stops accepting new ones but waits for existing ones to finish.server.closeIdleConnections()must be called immediately after to release the idle sockets that would otherwise hang the shutdown .- A re-entrancy guard is mandatory. Orchestrators may send
SIGTERMmultiple times. Without the guard, the second signal starts a second shutdown while the first is still running . - The force-exit timer must be
.unref()‘d. Without it, the timer keeps the event loop alive even after the shutdown is complete, and the process hangs until the timer fires . - The internal deadline must be shorter than the orchestrator’s grace period. Docker’s default is 10 seconds; an 8-second internal deadline leaves a 2-second margin .
- The exec form of
CMDis required in containers. Shell form runs Node.js as a child of/bin/sh, and the shell does not forwardSIGTERM. The Node.js process never sees the signal . - Testing the handler is not optional. Send
SIGTERMduring an in-flight request and verify that the request completes and the process exits with code 0. Test the timeout path by creating a hanging request and verifying the forced exit .
Remember: Graceful shutdown is not a single function call. It is a sequence: guard against re-entry, set a deadline, stop new connections, drain idle connections, close resources, and exit. The sequence has a specific order, and the order matters. The most common mistake is calling server.close() and assuming it closes everything—it does not. The second most common mistake is forgetting .unref() on the deadline timer, which turns the safety net into the thing that hangs the process. The third is using shell-form CMD in Docker, which means the signal never reaches Node.js at all. Each of these is a one-line fix, and each of them is routinely missed. The handler is tested by sending a signal during an in-flight request and verifying that the request completes. If it does not, the handler is not working.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!