Concept
The beginner framing: real applications need configuration that changes between environments (a database URL that's different in development vs. production) without hardcoding it into source files, and they need to shut down cleanly when told to stop, instead of just vanishing mid-request.
The precise mental model: every Node.js process exposes its environment variables through the read-only-in-spirit process.env object, and the process itself is a living thing with a lifecycle, it starts, it runs, and it can be asked to stop via signals, which your code can listen for and respond to before actually exiting.
console.log(process.env.NODE_ENV); // "development", "production", etc.
console.log(process.env.DATABASE_URL);Loading .env files: no longer a third-party job
For years, loading a .env file into process.env required a package like dotenv. This is no longer necessary for the basic case, confirmed directly against this app's installed Node v23.11.0 (node --help lists --env-file=... with no experimental prefix), --env-file is a native, stable flag.
# .env
PORT=3000
DATABASE_URL=postgres://localhost/mydb
# no dotenv package needed:
node --env-file=.env server.js// server.js
console.log(process.env.PORT); // "3000", loaded natively--env-file-if-exists=... is the same idea but silently does nothing if the file is missing, useful for optional environment-specific overrides.
Process lifecycle and signals
A running process can receive signals, OS-level notifications, the two most relevant for graceful shutdown being SIGINT (sent when you press Ctrl+C in a terminal) and SIGTERM (the standard, polite "please stop" signal sent by process managers, container orchestrators, and deploy tooling).
const server = require("http").createServer((req, res) => res.end("ok"));
server.listen(3000);
process.on("SIGTERM", () => {
console.log("SIGTERM received, closing server gracefully");
server.close(() => {
console.log("all in-flight requests finished, exiting now");
process.exit(0);
});
});Without this handler, an abrupt SIGTERM (the default behavior for most process managers during a deploy or scale-down) kills the process immediately, dropping any requests currently in flight, mid-response, with no chance to finish them.
Try It
Predict what happens before checking the solution.
const server = require("http").createServer((req, res) => {
setTimeout(() => res.end("done"), 2000); // simulate slow work
});
server.listen(3000);
process.on("SIGTERM", () => {
server.close(() => process.exit(0)); // server.close() stops accepting NEW connections
});A request arrives, and 500ms into its 2-second handler, SIGTERM fires. What happens to that in-flight request?
Solution
The in-flight request completes normally, server.close() stops the server from accepting new connections, but it does not forcibly terminate connections that are already being handled. The callback passed to server.close() only fires once all existing in-flight requests have finished, at which point process.exit(0) runs. This is exactly what makes it "graceful", no request in progress gets abruptly cut off.
Implement It Yourself
Build a minimal graceful-shutdown coordinator that tracks in-flight work and only exits once everything currently running has settled:
function createShutdownCoordinator() {
let inFlightCount = 0;
let shuttingDown = false;
const listeners = [];
return {
trackRequest(work) {
inFlightCount++;
return work().finally(() => {
inFlightCount--;
if (shuttingDown && inFlightCount === 0) {
listeners.forEach((fn) => fn());
}
});
},
onFullyDrained(
This mirrors what server.close()'s callback does internally, track active work, refuse new work once shutdown begins, and only signal "done" once the count reaches zero.
Under the Hood
Signals are handled via process.on(...), which is the exact same EventEmitter-based listener pattern covered in EventEmitter & Async Patterns, process itself is an EventEmitter instance, so SIGTERM/SIGINT handling is just a specific application of that general pattern, not a separate special mechanism.
Common Mistakes
1. Assuming .env loading still requires dotenv
npm install dotenv # ❌ no longer necessary for basic .env loadingnode --env-file=.env server.js # ✅ native, stableA lot of existing tutorials and boilerplate still default to installing dotenv out of habit, the native flag covers the common case without an extra dependency.
2. No SIGTERM handler at all
server.listen(3000); // no process.on("SIGTERM", ...) anywhereWithout a handler, the default behavior is immediate termination, any in-flight requests are simply dropped. In production, this is what causes intermittent errors during deploys/scale-downs if nothing else is in place.
3. Calling process.exit() immediately inside the SIGTERM handler
process.on("SIGTERM", () => {
console.log("shutting down");
process.exit(0); // ❌ exits immediately, doesn't wait for server.close()'s callback
});This defeats the entire purpose, process.exit() terminates the process right away, regardless of any in-flight work. The exit call belongs inside server.close()'s callback, after everything has actually finished.
Best Practices
- Use native
--env-file/--env-file-if-existsfor basic config loading instead of addingdotenvas a dependency, unless you need dotenv-specific features (variable expansion, multiple cascading files) it doesn't provide. - Always register a
SIGTERMhandler in anything that serves live traffic, process managers and orchestrators sendSIGTERMroutinely during normal deploys, not just failures. - Call
server.close()beforeprocess.exit(), and only exit inside its callback, so in-flight requests get to finish. - Set a hard timeout as a safety net, if graceful shutdown takes too long (a stuck connection, a runaway request), force-exit after a few seconds rather than hanging forever.
Performance Tips
- Graceful shutdown isn't a performance optimization itself, but its absence directly causes dropped requests and retries during every deploy, at scale, this shows up as a real, measurable error-rate spike correlated with deploy events.
- Reading
process.envis cheap and synchronous, there's no meaningful performance cost to reading it frequently, though caching a value you read once at startup into a local variable is still clearer than repeatedly indexing intoprocess.envthroughout a hot path.
