Node.js and npm Foundation
What Node.js is
Learn what Node.js adds to JavaScript, how its event loop decides what runs next, and how a program talks to the computer through process, exit codes and environment variables.
JavaScript outside the browser
Until now, every example ran the same way in the browser terminal and on your computer. That was possible because they only used the JavaScript language: values, functions, arrays, promises. From this lesson on, you will use things that only exist on a computer: files, network ports, environment variables.
JavaScript itself has no way to read a file or open a network connection. The program that runs your code adds those abilities. That program is called the runtime:
- In a browser, the runtime gives you
window,document(the web page) and buttons to click. It keeps you away from the files on the computer, for safety. - Node.js is a runtime for servers and your own computer. It has no web page, but it can read and write files, start servers, and see the whole machine.
Run this with Node.js on your computer:
console.log("window:", typeof window);
console.log("document:", typeof document);
console.log("process:", typeof process);
console.log("Node.js version:", process.version);
node where-am-i.jswindow: undefined document: undefined process: object Node.js version: v24.19.0
There is no window and no document, because there is no web page. Instead there is process, an object that describes the running program. You will use it a lot in this lesson. Your version number can be higher.
NOTE
From here on, most examples say Node.js only instead of showing a Run button. The browser terminal cannot open files or ports, so run those examples on your computer, as shown in the next section.A folder for this part of the course
Make one folder for the Node.js lessons and turn it into an ES module project, as you did in Set up your computer:
mkdir learn-node cd learn-node npm init -y Wrote to ~/learn-node/package.json: … npm pkg set type=module
Save each example in this folder with the file name shown above its code, then run it with node and the file name. For the example above:
node where-am-i.js window: undefined document: undefined process: object Node.js version: v24.19.0
What is inside Node.js
Node.js is built from a few large parts. You never call them directly, but knowing they exist explains how Node.js behaves:
- V8 is the JavaScript engine. It reads your code, turns it into machine code and runs it. Google wrote it for the Chrome browser, which is why Chrome and Node.js print the same error messages.
- libuv is a library written in C. It talks to the operating system: files, network, timers. It is the part that waits for slow things, so your JavaScript does not have to.
- The Node.js APIs are the JavaScript modules on top, such as
node:fsfor files andnode:httpfor servers. They pass your requests to libuv and give you back the results.
process.versions lists the version of each part:
const { node, v8, uv } = process.versions;
console.log("Node.js:", node);
console.log("V8 engine:", v8);
console.log("libuv:", uv);
node versions.jsNode.js: 24.19.0 V8 engine: 13.6.233.17-node.51 libuv: 1.52.1
The event loop
Your JavaScript runs on one thread: one line at a time, never two at once. Yet a Node.js server answers thousands of people. How?
In Asynchronous JavaScript you saw that code which has to wait says "call me back when the answer is ready" and moves on. In Node.js, libuv does the waiting. When a file has been read or a timer is due, libuv puts the callback in a queue. The event loop is the part of Node.js that takes the next callback from a queue and runs it, over and over, until there is nothing left to wait for. Then the program ends.
There are several queues, and the order between them matters when you debug. This example schedules work five different ways. It does so inside the callback of a file read, which is where server code almost always runs: inside a callback for "a request arrived" or "the database answered".
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
console.log("1. sync code in the callback");
setTimeout(() => console.log("6. setTimeout"), 0);
setImmediate(() => console.log("5. setImmediate"));
Promise.resolve().then(() => console.log("4. promise"));
process.nextTick(() => console.log("3. process.nextTick"));
console.log("2. more sync code");
});
node event-loop.js1. sync code in the callback 2. more sync code 3. process.nextTick 4. promise 5. setImmediate 6. setTimeout
import.meta.filename is the full path of the current file, so the program simply reads itself. Here is the order, and why:
- Synchronous code always finishes first. Nothing can interrupt it.
process.nextTickcallbacks run as soon as the current callback finishes, before anything else.- Promise callbacks (
.then, and the code after anawait) run next. Together withnextTick, these are called microtasks: small jobs that run before the event loop moves on. setImmediateruns right after the event loop finishes handling I/O (input and output, such as the file read).setTimeoutruns when the loop comes back round to check its timers, even with a delay of 0.
That order holds inside a callback, like the file read above. There is one exception at the very top of an ES module file (and every file in this course is one): Node is still finishing a promise of its own while it loads the module, so promise callbacks run before nextTick there:
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
node top-level.jssync promise nextTick
You rarely need to care which microtask runs first. What matters is the big picture: synchronous code, then microtasks, then the event loop's I/O, setImmediate and timers.
At the top level of a file, outside any I/O callback, the order of setTimeout(…, 0) and setImmediate is not fixed. It depends on how fast your computer started the program. Here is a two-line file, order.js, run four times:
node order.js immediate timeout node order.js immediate timeout node order.js immediate timeout node order.js timeout immediate
The lesson: never write code that depends on which of two timers wins. If B must happen after A, call B from A, or await A.
Do not block the loop
Because there is only one thread, a long piece of synchronous code stops everything: timers, requests, all of it. This example asks for a timer in 10 ms, then keeps the thread busy for 200 ms:
const start = Date.now();
setTimeout(() => {
console.log(`The timer asked for 10 ms and ran after ${Date.now() - start} ms`);
}, 10);
while (Date.now() - start < 200) {
// busy: this loop does nothing useful, but it holds the thread
}
console.log("The busy loop finished");
node blocking.jsThe busy loop finished The timer asked for 10 ms and ran after 218 ms
The timer was due after 10 ms, but it could not run until the loop let go of the thread. In a server, that means every other user waits. Heavy work (big calculations, reading huge files in one go) should be done in small pieces, with streams, or in a separate worker. You will meet streams in Streams and buffers.
The Node.js APIs
Node.js comes with dozens of built-in modules. You import them like your own modules, with the node: prefix, and you don't install anything. These are the ones a backend developer uses most:
node:fs/promises: read and write files and folders.node:path: build file paths that work on every operating system.node:http: HTTP servers and clients.node:crypto: random ids, hashes and encryption.node:events: objects that announce "something happened".node:streamandnode:readline: handle data piece by piece.node:os: facts about the computer.node:test: a built-in test runner.
A few things are global, so you use them with no import at all: process, console, timers, fetch, URL and Buffer. Older tutorials use require(…) to load modules. That is the CommonJS system, and it does not exist in an ES module:
console.log("fetch:", typeof fetch);
console.log("URL:", typeof URL);
console.log("Buffer:", typeof Buffer);
console.log("require:", typeof require);
node globals.jsfetch: function URL: function Buffer: function require: undefined
If you copy code that uses const fs = require("fs"), write import fs from "node:fs" instead. The next lesson, Files, paths and your computer, covers fs, path, os, crypto and events in detail.
The process object
A running program is called a process. The global process object is how your code talks to the operating system about it.
Command-line arguments
process.argv is an array of everything typed on the command line. The first two items are the path of node and the path of your file, so the words you typed start at index 2:
console.log("arguments:", process.argv.slice(2));
node args.jsarguments: []
Run without extra words, the list is empty. Now give it some. Quotes keep Buy milk together as one argument:
node args.js add "Buy milk" arguments: [ 'add', 'Buy milk' ]
Exit codes
When a program ends, it hands the operating system a number, the exit code. 0 means success. Anything else means failure. Scripts, test runners and servers such as Docker read this number to know whether your program worked.
const title = process.argv[2];
if (!title) {
console.error("Usage: node add.js <title>");
process.exitCode = 1;
} else {
console.log(`Added: ${title}`);
}
node add.jsUsage: node add.js <title>
console.error writes to the error output (stderr) instead of the normal output (stdout). Both appear in your terminal, but a script can tell them apart. process.exitCode = 1 sets the code the program ends with. In a terminal on macOS or Linux, echo $? prints the exit code of the last command. In PowerShell, use echo $LASTEXITCODE.
node add.js "Buy milk" Added: Buy milk echo $? 0 node add.js Usage: node add.js <title> echo $? 1
There is also process.exit(1), which stops the program at once. Setting process.exitCode is gentler: pending work, such as a file being written, still finishes first.
Where the program runs and how much memory it uses
process.cwd() is the current working directory: the folder your terminal was in when you started the program. It is not always the folder the file is in. import.meta.dirname gives you that one:
node where.js current working directory: ~/learn-node folder of this file: ~/learn-node cd .. node learn-node/where.js current working directory: ~ folder of this file: ~/learn-node
Remember this when you read files: a path like "tasks.json" is looked up from the working directory. The next lesson shows how to build paths that don't depend on it.
process.memoryUsage() reports how much memory the process uses, in bytes, and process.pid is the id the operating system gave it:
const toMb = (bytes) => (bytes / 1024 / 1024).toFixed(1) + " MB";
const { rss, heapUsed } = process.memoryUsage();
console.log("process id:", process.pid);
console.log("total memory (rss):", toMb(rss));
console.log("used by JavaScript objects:", toMb(heapUsed));
node memory.jsprocess id: 186159 total memory (rss): 51.0 MB used by JavaScript objects: 3.8 MB
Your numbers will differ on every run. rss (resident set size) is all the memory the process holds; heapUsed is the part your JavaScript objects use. If a server's memory only ever grows, something keeps objects it no longer needs. That is called a memory leak.
Environment variables
A program needs settings that change between your computer and the server: the port to listen on, the database address, secret keys. These must not be written in the code. Code is shared, copied and pushed to GitHub. Secrets must never be.
The standard answer is environment variables: named text values the operating system hands to every process it starts. Node.js puts them in process.env. Every value is a string, or undefined when it is not set.
const port = Number(process.env.PORT ?? 3000);
const apiKey = process.env.TASKS_API_KEY;
if (!apiKey) {
console.error("TASKS_API_KEY is not set. Copy .env.example to .env and fill it in.");
process.exit(1);
}
console.log(`Port: ${port}`);
console.log(`API key loaded (${apiKey.length} characters)`);
node config.jsTASKS_API_KEY is not set. Copy .env.example to .env and fill it in.
Nothing set TASKS_API_KEY, so the program refused to start. That is the right behaviour. A server that starts without its secret key fails later, in a confusing way, or worse, runs without protection. Notice also that the program never prints the key itself, only its length. Secrets do not belong in logs either.
PORT has a safe default (?? 3000) because a port is not a secret. A secret never gets a default.
A .env file
Typing every variable before each command is tedious, so developers keep them in a file called .env and let Node.js load it with --env-file. Create .env in your folder:
PORT=4000 TASKS_API_KEY=replace-me-with-a-long-random-value
To make a real random key, let Node.js generate one for you and paste it in:
node -p "crypto.randomBytes(32).toString('hex')" be4a80c451f09e6e13f07d6a63c35408c8ffadfc73bc7aa6cb222e402cb0aec7
Now run the program with the file:
node --env-file=.env config.js Port: 4000 API key loaded (35 characters) PORT=5000 node --env-file=.env config.js Port: 5000 API key loaded (35 characters) node --env-file=missing.env config.js node: missing.env: not found
Three things to notice:
- A variable that is already set in the terminal (
PORT=5000in front of the command) wins over the file. On a server, the hosting platform sets the real values this way. In PowerShell, set it first with$env:PORT=5000. - A missing file stops Node.js before your code runs. Use
--env-file-if-exists=.envwhen the file is optional, for example on a server that sets real variables instead. - Your code did not change at all. It only reads
process.env, wherever the values came from.
NEVER COMMIT .env
Your.env file holds real secrets, so it must never be committed to Git or shared. Add it to .gitignore before your first commit. Commit a .env.example instead, with the same names and empty or fake values, so other developers know which variables to set. If a secret is ever pushed by mistake, deleting the file is not enough: treat the secret as stolen and replace it.The two files for this project look like this:
# .gitignore node_modules/ .env # .env.example PORT=3000 TASKS_API_KEY=
Practice
TRY IT YOURSELF
Greet from the command line
Write greet.js that prints Hello, Ada! when you run node greet.js Ada, and Hello, world! when you give no name.
Show a solution
const name = process.argv[2] ?? "world";
console.log(`Hello, ${name}!`);
node greet.jsHello, world!
process.argv[2] is undefined when there is no argument, and ?? replaces undefined with the default.
TRY IT YOURSELF
Predict the order
Without running it, write down the order this program prints its letters in. Then run it and check.
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
setTimeout(() => console.log("A"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("C");
setImmediate(() => console.log("D"));
process.nextTick(() => console.log("E"));
});
Show a solution
node predict.js C E B D A
Synchronous C first, then the microtasks (nextTick E, then the promise B), then setImmediate D after the I/O, and the timer A last.
TRY IT YOURSELF
Check a setting properly
Change config.js so that PORT=abc stops the program with the message PORT must be a whole number between 1 and 65535, got "abc", instead of starting on port NaN.
Show a solution
const raw = process.env.PORT ?? "3000";
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error(`PORT must be a whole number between 1 and 65535, got "${raw}"`);
process.exit(1);
}
console.log(`Port: ${port}`);
node port.jsPort: 3000
PORT=abc node port.js PORT must be a whole number between 1 and 65535, got "abc"
Every value in process.env is text that someone typed. Check it before you trust it, exactly like input from a user.
Recap
- Node.js is a JavaScript runtime: V8 runs the code, libuv does the waiting, and built-in modules such as
node:fsandnode:httpreach the computer. - One thread runs your code. The event loop runs callbacks in order: sync code, microtasks (
nextTickand promises; at the top of an ES module, promises come first), then I/O,setImmediateand timers. Long synchronous work blocks everyone. process.argvholds the command-line arguments,process.exitCodereports success (0) or failure, andprocess.cwd()is where the program was started.- Settings and secrets come from
process.env. Load a.envfile withnode --env-file=.env, fail loudly when a secret is missing, and never commit.env.
Next, you will use the built-in modules to work with files, paths and the computer itself.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.