Learn / Node.js and npm
LESSON 19 OF 84

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.

  • 35 min to read and try
  • You need: The JavaScript lessons, especially Asynchronous JavaScript and Modules
  • You build: A learn-node folder with small programs that read arguments, exit codes and a .env file
Test yourself

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:

where-am-i.jsNode.js only
console.log("window:", typeof window);
console.log("document:", typeof document);
console.log("process:", typeof process);
console.log("Node.js version:", process.version);
Output of node where-am-i.js
window: 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:

Terminal on 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:

Terminal on your computer
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:fs for files and node:http for servers. They pass your requests to libuv and give you back the results.

process.versions lists the version of each part:

versions.jsNode.js only
const { node, v8, uv } = process.versions;

console.log("Node.js:", node);
console.log("V8 engine:", v8);
console.log("libuv:", uv);
Output of node versions.js
Node.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".

event-loop.jsNode.js only
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");
});
Output of node event-loop.js
1. 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:

  1. Synchronous code always finishes first. Nothing can interrupt it.
  2. process.nextTick callbacks run as soon as the current callback finishes, before anything else.
  3. Promise callbacks (.then, and the code after an await) run next. Together with nextTick, these are called microtasks: small jobs that run before the event loop moves on.
  4. setImmediate runs right after the event loop finishes handling I/O (input and output, such as the file read).
  5. setTimeout runs 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:

top-level.jsNode.js only
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Output of node top-level.js
sync
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:

Terminal on your computer
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:

blocking.jsNode.js only
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");
Output of node blocking.js
The 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:stream and node: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:

globals.jsNode.js only
console.log("fetch:", typeof fetch);
console.log("URL:", typeof URL);
console.log("Buffer:", typeof Buffer);
console.log("require:", typeof require);
Output of node globals.js
fetch: 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:

args.jsNode.js only
console.log("arguments:", process.argv.slice(2));
Output of node args.js
arguments: []

Run without extra words, the list is empty. Now give it some. Quotes keep Buy milk together as one argument:

Terminal on your computer
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.

add.jsNode.js only
const title = process.argv[2];

if (!title) {
  console.error("Usage: node add.js <title>");
  process.exitCode = 1;
} else {
  console.log(`Added: ${title}`);
}
Output of node add.js
Usage: 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.

Terminal on your computer
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:

Terminal on your computer
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:

memory.jsNode.js only
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));
Output of node memory.js
process 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.

config.jsNode.js only
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)`);
Output of node config.js
TASKS_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:

Terminal on your computer
node -p "crypto.randomBytes(32).toString('hex')"
be4a80c451f09e6e13f07d6a63c35408c8ffadfc73bc7aa6cb222e402cb0aec7

Now run the program with the file:

Terminal on your computer
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=5000 in 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=.env when 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
greet.jsNode.js only
const name = process.argv[2] ?? "world";
console.log(`Hello, ${name}!`);
Output of node greet.js
Hello, 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.

predict.jsNode.js only
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
Terminal on your computer
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
port.jsNode.js only
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}`);
Output of node port.js
Port: 3000
Terminal on your computer
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:fs and node:http reach the computer.
  • One thread runs your code. The event loop runs callbacks in order: sync code, microtasks (nextTick and promises; at the top of an ES module, promises come first), then I/O, setImmediate and timers. Long synchronous work blocks everyone.
  • process.argv holds the command-line arguments, process.exitCode reports success (0) or failure, and process.cwd() is where the program was started.
  • Settings and secrets come from process.env. Load a .env file with node --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.