The process Object

Every Node.js program has exactly one process object, and Node exposes it as a global — you never require or import it. It is your window into the operating-system process your script is running inside: the command-line arguments it was launched with, the environment variables available to it, the current working directory, memory usage, and the ability to exit cleanly or react to signals like Ctrl+C. Because process is also an EventEmitter, you can listen for lifecycle events such as unhandled errors or shutdown signals — the foundation for writing programs that shut down gracefully instead of dropping work mid-flight.

Overview: How process Works

process is not part of the JavaScript language — browsers don’t have it. It’s a global object that the Node.js runtime creates for you at startup, backed by libuv and the underlying operating system. Think of it as the bridge between your JavaScript code and the OS process (the same kind of process you’d see in ps or Task Manager) that node created when you ran your script.

A few of its most-used pieces:

  • process.argv — an array of the command-line arguments. Index 0 is always the path to the node executable, index 1 is the path to the script being run, and anything after that (from index 2 onward) is what the user actually typed. A common beginner mistake is forgetting the first two slots exist.
  • process.env — a plain object mirroring the environment variables the process inherited from its parent shell. Every value is a string, even things that look numeric or boolean, and changing process.env.X only affects this process’s in-memory copy — it does not write back to the shell or the OS.
  • process.cwd() — the current working directory, i.e. the directory you were in when you typed node. This is different from __dirname (the folder the running file lives in) — if you run node src/app.js from your project root, cwd() is the project root but __dirname is .../src.
  • process.exit() and process.exitCode — two different ways to end the program. process.exit() terminates immediately, even if there is unfinished asynchronous work (a file write, a pending timer); Node does not wait for the event loop to drain. Setting process.exitCode instead just records the exit code to use once the event loop naturally has nothing left to do — the safer default.
  • process.pid, process.platform ('linux', 'darwin', 'win32'), process.arch, process.version and process.versions — identifying information about the running process and the Node build (V8 version, OpenSSL version, and so on).
  • process.stdout, process.stderr, process.stdin — these are streams. console.log is, under the hood, mostly a formatted wrapper around process.stdout.write().

process also extends Node's EventEmitter, so you can call process.on(eventName, listener) to react to things happening to the process itself: it receiving a POSIX signal (SIGINT from Ctrl+C, SIGTERM from docker stop or kill), an error escaping every try/catch and every .catch() (uncaughtException, unhandledRejection), or the process about to end (exit, which only allows synchronous work — no more I/O can happen once it fires).

One more piece worth knowing early: process.nextTick(callback) schedules a callback to run before the event loop moves on to anything else — before even Promise microtasks, and long before timers or I/O. It's how Node's own internals defer work without letting an I/O event slip in first. The "Under the hood" section below shows exactly where it fits in the ordering.

Syntax

process is a global — there's no import or constructor call. You simply reference members on it:

process.argv                       // string[] — CLI arguments
process.env                        // object — environment variables (all strings)
process.cwd()                      // string — current working directory
process.chdir(path)                // change the working directory
process.exit(code)                 // terminate immediately (code defaults to 0)
process.exitCode = code            // request an exit code once the loop drains
process.pid                        // number — process ID
process.platform                   // 'linux' | 'darwin' | 'win32' | ...
process.version                    // Node version string, e.g. 'v22.10.0'
process.memoryUsage()              // object with rss, heapTotal, heapUsed, external
process.hrtime.bigint()            // bigint — high-resolution time in nanoseconds
process.nextTick(callback)         // queue callback before the next event-loop phase
process.on(eventName, listener)    // listen for 'exit', 'SIGINT', 'uncaughtException', ...
Member What it's for
argv Read command-line arguments (index 2+ are user-supplied)
env Read configuration/secrets injected by the shell, CI, or container
exit event Last chance to log/clean up synchronously before the process ends
SIGINT / SIGTERM Catch Ctrl+C or an orchestrator's stop signal to shut down gracefully
uncaughtException Last-resort handler for a thrown error nothing else caught
unhandledRejection Last-resort handler for a rejected promise nothing else caught

Examples

Example 1: Inspecting the current process

console.log("PID:", process.pid);
console.log("Platform:", process.platform);
console.log("Node version:", process.version);
console.log("Architecture:", process.arch);
console.log("Current working directory:", process.cwd());
console.log("Command-line arguments:", process.argv);

Output:

PID: 41823
Platform: linux
Node version: v22.10.0
Architecture: x64
Current working directory: /home/user/project
Command-line arguments: [ '/usr/local/bin/node', '/home/user/project/app.js' ]

The exact PID, platform, and paths depend on your machine — run it yourself with node app.js and compare. Notice that process.argv always has at least two entries before any arguments you actually pass.

Example 2: Reading CLI arguments and required environment variables

const args = process.argv.slice(2);
const name = args[0] ?? "stranger";
const port = process.env.PORT ?? 3000;

if (!process.env.API_KEY) {
  console.error("Missing required environment variable: API_KEY");
  process.exitCode = 1;
} else {
  console.log(`Hello, ${name}! Server would start on port ${port}.`);
}
node app.js Alice
API_KEY=secret123 node app.js Alice

Output:

Missing required environment variable: API_KEY
Hello, Alice! Server would start on port 3000.

The first run has no API_KEY set, so it prints an error and sets process.exitCode = 1 — the script keeps running the rest of its (synchronous) logic but will report a failing exit code to whatever launched it, without the abruptness of process.exit(). The second run supplies API_KEY inline before the command, which is how you pass one-off environment variables from a shell without editing any file. Notice args.slice(2) — that's how you strip off the node executable and script path to get just what the user typed.

Example 3: A server with graceful shutdown

const http = require("node:http");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("OK\n");
});

const port = process.env.PORT ?? 3000;
server.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

function shutdown(signal) {
  console.log(`\nReceived ${signal}, closing server...`);
  server.close(() => {
    console.log("Server closed. Exiting.");
    process.exitCode = 0;
  });
}

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

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled promise rejection:", reason);
  process.exitCode = 1;
});

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exitCode = 1;
});

Output (after starting it and pressing Ctrl+C):

Server listening on port 3000
^C
Received SIGINT, closing server...
Server closed. Exiting.

This is close to what you'd want in production: the server keeps running until it receives SIGINT (Ctrl+C) or SIGTERM (what Docker and most process managers send on stop), at which point server.close() stops accepting new connections but lets in-flight ones finish before the callback fires and the process exits. The two error-event handlers are a last line of defense — they make sure a stray thrown error or rejected promise gets logged and produces a non-zero exit code instead of the process crashing silently or hanging.

Under the Hood: Event Loop Ordering

process.nextTick() is one of the most misunderstood pieces of Node. It is not the same as setImmediate(), despite the similar-sounding names, and it does not go through the libuv event loop phases at all — it runs on a dedicated queue that is drained completely (including anything newly added to it) right after the current operation finishes, before Node even looks at Promise microtasks, let alone timers.

const fs = require("node:fs");

console.log("start");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("setTimeout"), 0);
  setImmediate(() => console.log("setImmediate"));
});

process.nextTick(() => console.log("nextTick"));

Promise.resolve().then(() => console.log("promise"));

console.log("end");

Output:

start
end
nextTick
promise
setImmediate
setTimeout

Step by step: the synchronous code runs first, printing start and end (the fs.readFile call just kicks off an async read and returns immediately). Once the main script finishes, Node drains the nextTick queue, then the Promise microtask queue — that's why nextTick always beats promise. Only after both queues are empty does the event loop move into its phases: timers, then poll (where the file read's callback fires), then check. Because the setTimeout and setImmediate calls happen inside the fs.readFile callback — i.e. during the poll phase — the loop moves straight into the check phase next, running setImmediate before it wraps back around to timers for setTimeout. That ordering (setImmediate before setTimeout) is guaranteed whenever both are scheduled inside an I/O callback; at the very top level of a script, without an I/O callback involved, the order between a 0ms timer and setImmediate is not guaranteed and can vary between runs.

Common Mistakes

Mistake 1: Calling process.exit() while async work is still pending

const fs = require("node:fs");

fs.writeFile("log.txt", "important log line\n", (err) => {
  if (err) console.error(err);
  console.log("Log written");
});

process.exit(0);

process.exit() terminates the process immediately — it does not wait for the event loop to drain. The write to log.txt may never finish, and "Log written" may never print, because the process can end before that callback gets a chance to run. This is a frequent source of "it works on my machine but the log file is empty in production" bugs.

const fs = require("node:fs");

fs.writeFile("log.txt", "important log line\n", (err) => {
  if (err) {
    console.error(err);
    process.exitCode = 1;
    return;
  }
  console.log("Log written");
});

Setting process.exitCode instead lets the pending write finish naturally; Node exits on its own, with the exit code you specified, once there is truly nothing left to do. Reach for process.exit() only when you deliberately want to abort immediately (for example, in a CLI tool right after a fatal, unrecoverable startup error where no cleanup is needed).

Mistake 2: Not handling unhandledRejection

async function loadConfig() {
  throw new Error("config file not found");
}

loadConfig();

console.log("Server starting...");

loadConfig() returns a rejected promise that nothing ever .catch()es. In current Node LTS versions the default behavior for an unhandled rejection is to print a warning and terminate the process — which can look like the app "randomly crashed" if you aren't watching for it. Never fire off an async function without handling its rejection.

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  process.exitCode = 1;
});

async function loadConfig() {
  throw new Error("config file not found");
}

loadConfig().catch((err) => {
  console.error("Failed to load config:", err.message);
  process.exitCode = 1;
});

console.log("Server starting...");

Add an explicit .catch() at every call site where you don't await a promise, and keep a top-level unhandledRejection listener as a safety net for anything that slips through — log it, set a non-zero exitCode, and let the process end predictably rather than in an undefined half-crashed state.

Best Practices

  • Prefer process.exitCode = n over process.exit(n) so pending I/O and timers get a chance to finish before the process actually ends.
  • Always register process.on("unhandledRejection", ...) and process.on("uncaughtException", ...) in long-running programs — log the error, set a non-zero exit code, and let the process end deliberately rather than crash unpredictably or keep running with corrupted state.
  • Validate required process.env variables at startup and fail fast with a clear message, instead of discovering a missing config value deep inside a request handler.
  • Never log the entire process.env object — it frequently contains secrets (API keys, database URLs, tokens).
  • Remember every value in process.env is a string; convert explicitly (Number(process.env.PORT)) rather than assuming numeric or boolean types.
  • Handle SIGTERM as well as SIGINT — container orchestrators (Docker, Kubernetes) send SIGTERM on shutdown, not Ctrl+C.
  • Only put synchronous code in an exit event listener — no more asynchronous work (timers, I/O) is allowed to run once that event fires.
  • Use process.hrtime.bigint() (or the perf_hooks module) rather than Date.now() when you need accurate, monotonic timing for benchmarks.

Practice Exercises

  • Write a script greet.js that reads a name from process.argv and prints Hello, <name>!. If the user also passes a second argument --loud, print the greeting in uppercase instead. (Hint: work with process.argv.slice(2).)
  • Write a script that requires a DATABASE_URL environment variable. If it's missing, print an error to process.stderr and set process.exitCode = 1 without throwing or calling process.exit(). Test it once with the variable set and once without.
  • Extend the graceful-shutdown server example: add a setInterval that logs process.memoryUsage().rss every 5 seconds, and make sure your SIGINT handler calls clearInterval before closing the server so nothing keeps the process alive after shutdown.

Summary

  • process is a global EventEmitter Node provides automatically — no import needed — representing the currently running OS process.
  • process.argv holds CLI arguments (real user arguments start at index 2); process.env holds environment variables, always as strings.
  • Prefer process.exitCode over process.exit() so pending asynchronous work finishes before the process actually ends.
  • process.nextTick() runs before Promise microtasks, which run before timers, which run before setImmediate callbacks scheduled at the top level — but inside an I/O callback, setImmediate reliably beats a 0ms setTimeout.
  • Always handle unhandledRejection and uncaughtException, and listen for SIGINT/SIGTERM to shut down cleanly instead of dropping in-flight work.