Skip to content
Node.js nd process 5 min read

Process Lifecycle & Exit Codes

Every Node.js program has a lifecycle: it starts, runs your code and any pending asynchronous work, and eventually exits with a numeric exit code that tells the operating system whether it succeeded or failed. Understanding how Node decides to exit — and how process.exit(), the exit event, and the beforeExit event fit together — is essential for writing reliable CLIs, servers, and scripts. Getting this wrong is the classic source of “my logs are missing” and “my database write never landed” bugs.

How Node decides to exit

Node.js does not stop because it reached the end of your file. It keeps running as long as there is pending work on the event loop: open timers, active sockets, file handles, or scheduled callbacks. When the event loop has nothing left to do, Node drains, emits a few lifecycle events, and exits naturally with code 0.

This is why a script with a setInterval never terminates, and why a simple console.log script exits immediately. You rarely need to call process.exit() yourself — letting the loop empty is the cleanest way to shut down.

// natural-exit.mjs
console.log("Doing work...");
setTimeout(() => {
  console.log("Work finished, loop is now empty.");
}, 100);
// No process.exit() needed — Node exits 0 once the timer fires.

Output:

Doing work...
Work finished, loop is now empty.

Exit codes and their conventions

The exit code is a small integer returned to the shell. By convention 0 means success and any non-zero value means failure. You can read or set it via process.exitCode, or pass it to process.exit(code).

CodeMeaning
0Success — normal completion
1Uncaught fatal exception or general error
2Misuse of a shell builtin (reserved by Bash)
>128Process terminated by signal N → exit code 128 + N (e.g. SIGINT → 130)

Node also defines internal codes such as 9 (invalid argument) and 12 (invalid inspector port). For your own application errors, stick to small non-zero numbers and document them. In a shell you can inspect the last exit code with $?.

node natural-exit.mjs
echo "Exit code was: $?"

Output:

Doing work...
Work finished, loop is now empty.
Exit code was: 0

Prefer setting process.exitCode = 1 over calling process.exit(1). Setting exitCode lets the event loop drain naturally so pending writes complete, while still exiting non-zero.

The beforeExit and exit events

The process object emits two distinct lifecycle events:

  • beforeExit fires when the event loop has emptied and Node is about to exit. Crucially, you can still schedule more asynchronous work here (start a timer, make a request), which keeps the process alive for another loop. It does not fire when the process is terminated via process.exit() or by an uncaught exception.
  • exit fires as the very last step. At this point only synchronous work runs — the event loop is already shut down, so any async callback you schedule will simply never run. The listener receives the final exit code.
// lifecycle-events.mjs
let drained = false;

process.on("beforeExit", (code) => {
  if (!drained) {
    drained = true;
    // We can still schedule async work here.
    setTimeout(() => console.log("One more task before exit"), 50);
  }
  console.log(`beforeExit with code ${code}`);
});

process.on("exit", (code) => {
  // Only synchronous code runs here.
  console.log(`exit with code ${code}`);
});

console.log("Main module finished");

Output:

Main module finished
beforeExit with code 0
One more task before exit
beforeExit with code 0
exit with code 0

Why process.exit() can truncate I/O

process.exit() forces Node to shut down immediately, bypassing the event loop. Any work that has not yet flushed — buffered writes to a file, in-flight network responses, queued console.log output to a pipe — is silently discarded. Because process.stdout and process.stderr are sometimes asynchronous (when connected to a pipe or file rather than a TTY), calling process.exit() right after logging can drop the log line entirely.

// truncated.mjs  — DON'T do this when stdout is piped
import { writeFile } from "node:fs/promises";

writeFile("out.txt", "important data").then(() => {
  console.log("File written");
});

process.exit(0); // Loop is killed before the write completes — out.txt may be empty!

The fix is to let the work finish and only then signal the exit code:

// graceful.mjs
import { writeFile } from "node:fs/promises";

try {
  await writeFile("out.txt", "important data");
  console.log("File written");
  process.exitCode = 0; // mark success, let the loop drain naturally
} catch (err) {
  console.error("Write failed:", err.message);
  process.exitCode = 1;
}

Reserve process.exit() for cases where you genuinely cannot wait — for example after an unrecoverable error where you’ve already flushed what you need synchronously. For normal flow, set process.exitCode and return.

Graceful exit patterns

A robust shutdown waits for in-flight work, closes resources, then exits. The pattern below combines a top-level handler with a timeout safety net so a stuck resource can never hang the process forever.

// server-shutdown.mjs
import { createServer } from "node:http";

const server = createServer((req, res) => res.end("ok")).listen(3000);

async function shutdown(signal) {
  console.log(`Received ${signal}, closing server...`);
  // Stop accepting connections and wait for existing ones to finish.
  await new Promise((resolve) => server.close(resolve));
  console.log("Server closed cleanly");
  process.exitCode = 0;
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

// Safety net: force-exit if cleanup hangs.
process.on("SIGTERM", () => setTimeout(() => process.exit(1), 10_000).unref());

The .unref() call lets the timer exist without keeping the process alive on its own — it only matters if shutdown is still pending after ten seconds.

Best Practices

  • Prefer process.exitCode = N and let the event loop drain instead of calling process.exit(N) mid-flow.
  • Reserve process.exit() for truly unrecoverable states, and never place it immediately after an async console.log or write when output is piped.
  • Use 0 for success and small, documented non-zero codes for distinct failure modes.
  • Do exit-event cleanup synchronously only — async callbacks scheduled there will never run.
  • Use beforeExit for last-chance async work, but guard against re-entry to avoid an infinite loop.
  • Add a setTimeout(...).unref() safety net so a stuck resource cannot block shutdown forever.
  • Always await critical I/O (logging, DB commits, file writes) before signalling the exit code.
Last updated June 14, 2026
Was this helpful?