The Node.js runtime

Intermediate55 min

See what Node.js adds to the language: built-in modules with the node prefix, events and EventEmitter, the process object, environment variables, .env files with --env-file, and the Web APIs that Node shares with browsers.

What you will learn
  • Explain what Node.js adds to the JavaScript language and what it leaves out
  • Import built-in modules with the node prefix and tell which globals exist in Node
  • Listen for and emit events with EventEmitter, and wait for one with events.once
  • Read process.argv, process.env and the exit event
  • Build a small, validated configuration object from environment variables
  • Load variables from a .env file with --env-file, util.parseEnv and process.loadEnvFile
On this page

Every lesson so far has been about the language: values, functions, classes, promises. The language says nothing about files, networks or command-line arguments. Those come from the host that runs your code. In a browser the host adds document, window and events. In Node.js it adds files, processes, sockets and a standard library. This module is about that second host.

Node.js is three things in one program: the V8 engine, an event loop (the one from the event loop lesson), and a library of built-in modules. The install lesson covers installing it, and tools of the trade explains the terminal you will use here.

Which Node? Use the latest 22.x release, or Node 24 or 26. Every example was run on 22.22, 24.21 and 26.11. When a feature needs a particular minor release of 22, the lesson says so.

Built-in modules and the node: prefix

Node ships with modules such as node:fs for files, node:path for file names, node:os for facts about the machine, and node:test for tests. You import them like any ES module, with the node: prefix in front of the name.

nr_builtins.mjs
import { isBuiltin } from "node:module";
console.log(isBuiltin("node:fs"), isBuiltin("fs"));
console.log(isBuiltin("express"));
console.log(isBuiltin("node:test"), isBuiltin("test"));
Output
true true
false
true false

The prefix tells the reader that this is part of Node and not an npm package, and it protects you from a package with the same name. A few modules, node:test among them, can only be reached with the prefix. Use it everywhere in new code.

Globals: the same language, a different global object

In Node there is no window and no document, because there is no page. The standard globalThis exists in both worlds (Node also calls it global).

nr_globals.mjs
console.log(typeof window, typeof document);
console.log(typeof process, typeof fetch, typeof URL);
console.log(globalThis === global);
console.log(typeof require, typeof __dirname);
Output
undefined undefined
object function function
true
undefined undefined
  • process is Node’s own global, described below.
  • fetch, URL, AbortController, TextEncoder, structuredClone, performance and the timer functions are Web APIs: Node implements the same names as browsers, so code that uses only them runs in both. fetch has been stable since Node 21 and the WebSocket client since 22.4.
  • require and __dirname belong to the older CommonJS module system. In an ES module (a .mjs file) they do not exist. The CommonJS lesson explains where you will still meet them.

Events and EventEmitter

Much of Node rests on one small idea: an object announces that something happened, and the functions that asked to hear about it are called. The class is EventEmitter from node:events. The process object, streams, HTTP servers and child processes are all emitters, so the next lessons keep using .on(...).

  • on(name, listener) registers a listener. once(name, listener) registers one that removes itself after its first call. off(name, listener) removes one.
  • emit(name, ...args) calls the listeners synchronously, in the order they were registered, passing the arguments along. It returns true if at least one listener ran.
nr_events.mjs
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.on("greet", (name) => console.log("hello,", name));
bus.once("greet", () => console.log("first greeting only"));
console.log(bus.emit("greet", "Ada"));
console.log(bus.emit("greet", "Alan"));
console.log(bus.emit("nobody-listens"));
Output
hello, Ada
first greeting only
true
hello, Alan
true
false

Both listeners ran inside the first emit, before it returned. Names are free text, with one exception: "error" is special. Emitting it with no listener throws the error, which would end your program. So code that can fail (streams, servers, child processes) emits "error", and you must listen for it.

nr_events_error.mjs
import { EventEmitter } from "node:events";
const job = new EventEmitter();
job.on("error", (error) => console.log("handled:", error.message));
job.emit("error", new Error("disk full"));
const bare = new EventEmitter();
try {
bare.emit("error", new Error("nobody listens"));
} catch (error) {
console.log("thrown:", error.message);
}
Output
handled: disk full
thrown: nobody listens

Callbacks are awkward with async code. The function once(emitter, name) from node:events (not the method) returns a promise that resolves with the array of the event’s arguments. If the emitter emits "error" first, the promise rejects with it. An AbortSignal can be passed as { signal } to stop waiting.

A stream is an emitter that fires "data" for each chunk, "end" when there is no more, and "error" when something fails. Everything in the streams lesson is built from events like these.

nr_events_once.mjs
import { EventEmitter, once } from "node:events";
import { Readable } from "node:stream";
const task = new EventEmitter();
setTimeout(() => task.emit("done", 42, "ok"), 10);
const [value, label] = await once(task, "done");
console.log(value, label);
setTimeout(() => task.emit("error", new Error("task failed")), 10);
await once(task, "done").catch((error) => console.log(error.message));
const stream = Readable.from(["a", "b"]);
console.log(stream instanceof EventEmitter, process instanceof EventEmitter);
stream.on("data", (chunk) => console.log("data:", chunk));
stream.on("end", () => console.log("end"));
Output
42 ok
task failed
true true
data: a
data: b
end

The process object

process describes the running program and lets you talk to the outside world. It is an EventEmitter, which is why process.on(...) works.

nr_process.mjs
console.log(process.argv.length, process.argv.slice(2));
const major = Number(process.versions.node.split(".")[0]);
console.log("Node 22 or newer:", major >= 22);
process.on("exit", (code) => console.log("exit event, code", code));
console.log("last line of the script");
Output
2 []
Node 22 or newer: true
last line of the script
exit event, code 0
  • process.argv is an array of strings: the path of node, then your script, then whatever the user typed. That is why slice(2) gives the real arguments. The command-line tools lesson builds on this.
  • process.versions.node is the version as a string such as "26.11.1".
  • The "exit" listener runs just before the program ends, for whatever reason. It must do only synchronous work: queued timers and promises are abandoned.

process.exit(code) ends the program at once, even with writes still on their way. Prefer to set process.exitCode = 1 and let the program finish normally.

Environment variables

Every program starts with environment variables: named strings that the parent process (usually your shell) hands down. They configure a program without editing its code: a port number, a database address, a switch for debug output. Node shows them as the object process.env.

nr_env.mjs
process.env.RETRIES = 3;
console.log(typeof process.env.RETRIES, process.env.RETRIES + 1);
console.log(Number(process.env.RETRIES) + 1);
console.log(process.env.TUTORIAL_NOT_SET);
console.log(process.env.TUTORIAL_NOT_SET ?? "default value");
delete process.env.RETRIES;
console.log("RETRIES" in process.env);
Output
string 31
4
undefined
default value
false
  • Values are always strings. Assigning the number 3 stores "3", and "3" + 1 is "31". Convert with Number(...) yourself. (Assigning a non-string is deprecated, DEP0104; write the string.)
  • A variable that is not set reads as undefined, so ?? is the natural way to supply a default.
  • Use delete to remove a variable. Changes affect only your process and the children it starts later, never your shell.

Turning the environment into a config object

Reading process.env all over a program hides what it needs, and a typo such as PROT fails silently. Read the environment once, at the start, into a plain object with defaults and checks. Taking the environment as a parameter makes the function easy to test.

nr_config.mjs
function loadConfig(env) {
const port = Number(env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1) {
throw new Error(`PORT must be a positive integer, got "${env.PORT}"`);
}
return { port, debug: env.DEBUG === "1", name: env.APP_NAME ?? "app" };
}
console.log(loadConfig({}));
console.log(loadConfig({ PORT: "8080", DEBUG: "1" }));
try {
loadConfig({ PORT: "eighty" });
} catch (error) {
console.log(error.message);
}
Output
{ port: 3000, debug: false, name: 'app' }
{ port: 8080, debug: true, name: 'app' }
PORT must be a positive integer, got "eighty"

The program fails at start-up with a clear message. A real program calls loadConfig(process.env) once and passes the result around.

.env files and –env-file

Setting a dozen variables by hand before every run is tedious. The convention is a text file named .env, one NAME=value per line, and Node reads it for you with --env-file. Here is a .env file, with a comment and a quoted value, and a program that prints three variables.

.env
APP_GREETING=hello
APP_NAME="Ada L"
# a comment
APP_PORT=8080
nr_dotenv.mjs
const { APP_GREETING, APP_NAME, APP_PORT } = process.env;
console.log(APP_GREETING, APP_NAME, APP_PORT);

Now run it the way you would in a terminal. The second command sets APP_PORT in the real environment first. The fourth names a file that does not exist.

Terminal window
$ node --env-file=.env nr_dotenv.mjs
hello Ada L 8080
$ APP_PORT=9000 node --env-file=.env nr_dotenv.mjs
hello Ada L 9000
$ node nr_dotenv.mjs
undefined undefined undefined
$ node --env-file=missing.env nr_dotenv.mjs
node: missing.env: not found
$ echo $?
9
$ node --env-file-if-exists=missing.env nr_dotenv.mjs
missing.env not found. Continuing without it.
undefined undefined undefined

The quotes around Ada L are gone and the comment is ignored. A variable that is already set in the real environment wins over the file, which is why the second run prints 9000: a deployment can override your defaults. A missing file is an error (exit status 9) unless you use --env-file-if-exists, which needs Node 22.9 or later. --env-file works on every 22.x. It was flagged experimental until 22.21 and 24.10, but behaves the same.

From inside a program, util.parseEnv(text) turns the text of a .env file into an object without touching process.env, and process.loadEnvFile(path) loads a file into process.env without overriding variables that exist. Both were flagged experimental until the same releases.

nr_parseenv.mjs
import { parseEnv } from "node:util";
const text = 'A=1\n# note\nB="two words"\nexport C=3\n';
const parsed = parseEnv(text);
console.log(Object.entries(parsed));
Output
[ [ 'A', '1' ], [ 'B', 'two words' ], [ 'C', '3' ] ]

An export before the name is ignored. Never commit a .env file that holds passwords or API keys. Add it to .gitignore, and commit an example file with fake values instead.

Common pitfalls

  • Expecting browser globals. window, document and localStorage do not exist in Node.
  • Forgetting that env values are strings. process.env.DEBUG = false stores "false", which is truthy. Compare against an exact string such as "1".
  • Emitting "error" with no listener. It throws. Listen for it on every stream and server.
  • Calling process.exit() in the middle of work. Pending writes and timers are dropped. Set process.exitCode instead.
  • Reading process.env everywhere. Read it once into a config object.
  • Using require or __dirname in a .mjs file. They are not defined there.
  • Committing secrets in .env files.

Exercises

ExerciseParsing a boolean flag

Environment variables are strings, so DEBUG=false is a trap. Write flag(env, name) that returns true when env[name] is one of "1", "true" or "yes" (in any letter case) and false otherwise, including when the variable is missing. Test it on a plain object with the values 1, TRUE, no, false and a missing name, and print the results on one line, separated by spaces.

Expected output:

true true false false false
Show solution

Lower-casing the value makes the comparison case-insensitive, and the optional chaining handles a missing variable.

nr_ex_flag.mjs
function flag(env, name) {
const value = env[name]?.toLowerCase();
return ["1", "true", "yes"].includes(value);
}
const env = { A: "1", B: "TRUE", C: "no", D: "false" };
const names = ["A", "B", "C", "D", "MISSING"];
console.log(names.map((name) => flag(env, name)).join(" "));
Output
true true false false false
ExercisePrefixed settings

A program reads every variable that starts with APP_ as a setting. Write settings(env) that returns an object with the prefix removed and the rest of the name in lower case. Try it on { APP_PORT: "80", APP_NAME: "demo", HOME: "/home/x", APPLE: "red" } and print the result with console.log.

Expected output:

{ port: '80', name: 'demo' }
Show solution

Object.entries gives pairs, filter keeps the prefixed ones, and Object.fromEntries builds the result. Note that APPLE does not match, because the prefix includes the underscore.

nr_ex_prefix.mjs
function settings(env) {
const prefix = "APP_";
return Object.fromEntries(
Object.entries(env)
.filter(([key]) => key.startsWith(prefix))
.map(([key, value]) => [key.slice(prefix.length).toLowerCase(), value]),
);
}
const env = { APP_PORT: "80", APP_NAME: "demo", HOME: "/home/x", APPLE: "red" };
console.log(settings(env));
Output
{ port: '80', name: 'demo' }
ExerciseFile values and real values

Use util.parseEnv on the text MODE=dev\nPORT=3000\nCOLOR=blue\n. Then merge it with a pretend real environment { PORT: "9000" } so that the real environment wins, the way --env-file behaves. Print the values of MODE, PORT and COLOR, in that order, on one line.

Expected output:

dev 9000 blue
Show solution

With object spread, later values replace earlier ones. Putting the real environment last makes it win. The solution reads the keys by name, because the order of the keys in the parsed object is not something to rely on.

nr_ex_merge.mjs
import { parseEnv } from "node:util";
const fromFile = parseEnv("MODE=dev\nPORT=3000\nCOLOR=blue\n");
const real = { PORT: "9000" };
const merged = { ...fromFile, ...real };
console.log(merged.MODE, merged.PORT, merged.COLOR);
Output
dev 9000 blue
ExerciseWait for an event

Make an EventEmitter called loader that, after 20 ms, emits "loaded" with the arguments "config.json" and 3. Use once from node:events with await to receive them, and print loaded config.json 3. Then show that once rejects: 20 ms later emit "error" with new Error("bad file") while waiting for "loaded" again, and print the message of the error.

Expected output:

loaded config.json 3
bad file
Show solution

once resolves with an array of the event’s arguments, so destructuring picks them apart. When "error" is emitted while once waits for another event, the promise rejects with that error.

nr_ex_wait.mjs
import { EventEmitter, once } from "node:events";
const loader = new EventEmitter();
setTimeout(() => loader.emit("loaded", "config.json", 3), 20);
const [file, count] = await once(loader, "loaded");
console.log("loaded", file, count);
setTimeout(() => loader.emit("error", new Error("bad file")), 20);
await once(loader, "loaded").catch((error) => console.log(error.message));
Output
loaded config.json 3
bad file

Summary

  • Node.js is V8 plus an event loop plus built-in modules. It adds files, processes and networking, and it has no DOM.
  • Import built-ins with the node: prefix. globalThis exists everywhere; Node also provides the Web APIs fetch, URL, AbortController and others.
  • EventEmitter has on, once, off and emit. Listeners run synchronously in order, an "error" event with no listener throws, and once(emitter, name) from node:events turns an event into a promise. Streams, servers and process are emitters.
  • process.argv holds the arguments, process.env holds string variables, and the exit event runs synchronous clean-up. Prefer process.exitCode to process.exit().
  • Read the environment once into a validated config object.
  • --env-file loads a .env file (real variables win; --env-file-if-exists needs 22.9). util.parseEnv and process.loadEnvFile do the same from code.

Next: reading and writing files, and the path and URL tools that go with them.