The Node.js runtime
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.
import { isBuiltin } from "node:module";
console.log(isBuiltin("node:fs"), isBuiltin("fs"));console.log(isBuiltin("express"));console.log(isBuiltin("node:test"), isBuiltin("test"));true truefalsetrue falseThe 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).
console.log(typeof window, typeof document);console.log(typeof process, typeof fetch, typeof URL);console.log(globalThis === global);console.log(typeof require, typeof __dirname);undefined undefinedobject function functiontrueundefined undefinedprocessis Node’s own global, described below.fetch,URL,AbortController,TextEncoder,structuredClone,performanceand the timer functions are Web APIs: Node implements the same names as browsers, so code that uses only them runs in both.fetchhas been stable since Node 21 and theWebSocketclient since 22.4.requireand__dirnamebelong to the older CommonJS module system. In an ES module (a.mjsfile) 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 returnstrueif at least one listener ran.
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"));hello, Adafirst greeting onlytruehello, AlantruefalseBoth 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.
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);}handled: disk fullthrown: nobody listensCallbacks 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.
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"));42 oktask failedtrue truedata: adata: bendThe 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.
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");2 []Node 22 or newer: truelast line of the scriptexit event, code 0process.argvis an array of strings: the path ofnode, then your script, then whatever the user typed. That is whyslice(2)gives the real arguments. The command-line tools lesson builds on this.process.versions.nodeis 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.
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);string 314undefineddefault valuefalse- Values are always strings. Assigning the number
3stores"3", and"3" + 1is"31". Convert withNumber(...)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
deleteto 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.
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);}{ 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.
APP_GREETING=helloAPP_NAME="Ada L"# a commentAPP_PORT=8080const { 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.
$ node --env-file=.env nr_dotenv.mjshello Ada L 8080$ APP_PORT=9000 node --env-file=.env nr_dotenv.mjshello Ada L 9000$ node nr_dotenv.mjsundefined undefined undefined$ node --env-file=missing.env nr_dotenv.mjsnode: missing.env: not found$ echo $?9$ node --env-file-if-exists=missing.env nr_dotenv.mjsmissing.env not found. Continuing without it.undefined undefined undefinedThe 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.
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));[ [ '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,documentandlocalStoragedo not exist in Node. - Forgetting that env values are strings.
process.env.DEBUG = falsestores"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. Setprocess.exitCodeinstead. - Reading
process.enveverywhere. Read it once into a config object. - Using
requireor__dirnamein a.mjsfile. They are not defined there. - Committing secrets in
.envfiles.
Exercises
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 falseShow solution
Lower-casing the value makes the comparison case-insensitive, and the optional chaining handles a missing variable.
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(" "));true true false false falseA 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.
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));{ port: '80', name: 'demo' }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 blueShow 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.
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);dev 9000 blueMake 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 3bad fileShow 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.
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));loaded config.json 3bad fileSummary
- 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.globalThisexists everywhere; Node also provides the Web APIsfetch,URL,AbortControllerand others. EventEmitterhason,once,offandemit. Listeners run synchronously in order, an"error"event with no listener throws, andonce(emitter, name)fromnode:eventsturns an event into a promise. Streams, servers andprocessare emitters.process.argvholds the arguments,process.envholds string variables, and theexitevent runs synchronous clean-up. Preferprocess.exitCodetoprocess.exit().- Read the environment once into a validated config object.
--env-fileloads a.envfile (real variables win;--env-file-if-existsneeds 22.9).util.parseEnvandprocess.loadEnvFiledo the same from code.
Next: reading and writing files, and the path and URL tools that go with them.