Emmanuel Akinfulubi

Starting out all 3 written

What Node actually is, how to run a file, and how to read the error you are certainly about to get.

1 What Node actually is, and what it is not

Node is a program that runs JavaScript on your own computer instead of inside a web page. That is the whole idea. The rest of this course is what becomes possible once JavaScript can read files, start other programs and answer network requests.

Your first program

Every panel on this site is a real file that was run by a real node when the page was built. The file is shown with its name, the command underneath it is exactly what was typed, and the output is exactly what came back.

Hello

ran at build time

example.mjs

console.log("Hello from Node.");
console.log("Running on Node", process.version);

check("this is Node 26", process.version.startsWith("v26."), true);

What node printed, when the build ran it

node example.mjs

Hello from Node.
Running on Node v26.7.0
ok   this is Node 26

The last line is a check. check(name, got, want) prints ok when the two values are equal and FAIL when they are not, and a single FAIL anywhere stops this site from being built at all. It is how every claim on these pages is held to account. The next lesson shows where it comes from.

Two pieces: an engine, and everything around it

Node is built from two main parts. V8 is the JavaScript engine from Google Chrome: it turns your code into machine instructions and runs it. libuv is a library that talks to the operating system on Node's behalf — files, network, timers — and it is what lets one JavaScript program wait on many of those at once.

What this Node is made of

ran at build time

example.mjs

const { node, v8, uv, openssl } = process.versions;

console.log("node   ", node);
console.log("v8     ", v8, "  <- the engine Chrome uses");
console.log("libuv  ", uv, "  <- the part that talks to the operating system");
console.log("openssl", openssl, "  <- encryption, for https and crypto");

check("Node reports the V8 it was built with", typeof v8, "string");
check("and the libuv underneath it", uv.split(".").length, 3);

What node printed, when the build ran it

node example.mjs

node    26.7.0
v8      14.6.202.34-node.28   <- the engine Chrome uses
libuv   1.52.1   <- the part that talks to the operating system
openssl 3.6.4   <- encryption, for https and crypto
ok   Node reports the V8 it was built with
ok   and the libuv underneath it

The language is the same

JavaScript the language — values, functions, objects, loops — is identical in Node and in a browser, because it is the same engine. This panel is marked runs here too: press Run here and your browser runs the same code. Before this page was published, the build ran it both ways and confirmed the two printed exactly the same thing.

Plain JavaScript

ran in node · runs here too

example.mjs

const prices = [4, 12, 7];
const total = prices.reduce((sum, p) => sum + p, 0);

console.log("items:", prices);
console.log("total:", total);

check("the total adds up", total, 23);

What node printed, when the build ran it

node example.mjs

items: [ 4, 12, 7 ]
total: 23
ok   the total adds up

What is around the language is not

A browser gives JavaScript a page to control: window, document, the DOM. Node has none of that, because there is no page. Instead it gives JavaScript the machine: process, the file system, the network.

What exists here, and what does not

ran at build time

example.mjs

console.log("window:  ", typeof window);
console.log("document:", typeof document);
console.log("process: ", typeof process);
console.log();
console.log("Both have these, because both are web-standard:");
console.log("  fetch:     ", typeof fetch);
console.log("  setTimeout:", typeof setTimeout);
console.log("  URL:       ", typeof URL);

check("there is no page to control", typeof document, "undefined");
check("but there is a process", typeof process.pid, "number");
check("and fetch is built in", typeof fetch, "function");

What node printed, when the build ran it

node example.mjs

window:   undefined
document: undefined
process:  object

Both have these, because both are web-standard:
  fetch:      function
  setTimeout: function
  URL:        function
ok   there is no page to control
ok   but there is a process
ok   and fetch is built in

The overlap is growing. fetch, URL, setTimeout, TextEncoder, crypto.randomUUID and WebSocket are all in both now, deliberately, so code can move between them. But anything about a page stays in the browser, and anything about the machine stays in Node.

The standard library

Node ships with dozens of built-in modules. You import them by name, with a node: prefix that makes it unambiguous you mean the built-in and not a package with the same name.

Batteries included

ran at build time

example.mjs

import { builtinModules } from "node:module";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const publicOnes = builtinModules.filter((name) => !name.startsWith("_"));
console.log(publicOnes.length, "built-in modules, including:");
console.log(" ", ["fs", "http", "path", "crypto", "stream", "child_process"].join(", "));
console.log(" ", builtinModules.filter((name) => name.startsWith("node:")).join(", "), "(prefix only)");
console.log();

// Two of them, used for real: read this very file, and build a file path.
const me = readFileSync(import.meta.filename, "utf8");
console.log("this file starts:", JSON.stringify(me.slice(0, 40)));
console.log("a path, built for this OS:", join("reports", "2026", "q3.csv"));

check("fs is built in", publicOnes.includes("fs"), true);
check("so is an HTTP server", publicOnes.includes("http"), true);
check("SQLite and a test runner are too, under node: only",
      builtinModules.includes("node:sqlite") && builtinModules.includes("node:test"), true);

What node printed, when the build ran it

node example.mjs

58 built-in modules, including:
  fs, http, path, crypto, stream, child_process
  node:sea, node:sqlite, node:test, node:test/reporters (prefix only)

this file starts: "import { builtinModules } from \"node:mod"
a path, built for this OS: reports/2026/q3.csv
ok   fs is built in
ok   so is an HTTP server
ok   SQLite and a test runner are too, under node: only

This course installs nothing. Node has an HTTP server, a test runner, a SQLite database, a file watcher and a TypeScript loader built in, and every one of them is used in these lessons. Real projects add packages from npm, and lesson 23 shows exactly what that does to your folder — but a package is easier to judge once you know what it is wrapping.

A program runs until there is nothing left to wait for

A browser tab stays open until you close it. A Node program exits by itself — but only once it has nothing left to do. A timer that has not fired yet counts as something to do.

Node waits for pending work

ran at build time

example.mjs

const started = Date.now();
const order = [];

setTimeout(() => {
  order.push("timer");
  const waited = Date.now() - started;
  console.log("timer fired after about", Math.round(waited / 50) * 50, "ms");
  check("the program waited for the timer", waited >= 100, true);
  check("and the last line ran first", order, ["last line", "timer"]);
}, 100);

order.push("last line");
console.log("reached the last line of the file");

What node printed, when the build ran it

node example.mjs

reached the last line of the file
timer fired after about 100 ms
ok   the program waited for the timer
ok   and the last line ran first

The last line ran first, and then Node waited — it did not exit at the end of the file, because a timer was still pending. That waiting is done by the event loop, and it is the single most important idea in Node. Lesson 25 takes it apart properly.

From a terminal

You run Node by typing node at a terminal prompt. These are real commands, run in order, with what each one really printed.

node, typed at a prompt

ran at build time

The terminal, as the build ran it

node --version
v26.7.0
node -e 'console.log(6 * 7)'
42
node -p '"shout".toUpperCase()'
SHOUT

-e evaluates a line of code, and -p evaluates it and prints the result. Both are handy for a quick question. For anything longer, write a file — which is the next lesson.

  • Node runs JavaScript outside a browser: the V8 engine, plus libuv to talk to the operating system.
  • The language is identical. What surrounds it is not: no window or document, but process, files and the network.
  • Built-in modules are imported with a node: prefix. There are dozens, and this course needs nothing else.
  • A Node program exits when nothing is left pending. The event loop is what does the waiting.
  • Every panel here ran for real, and check() is how each one proves its claim.

2 Running a file, and console.log

A Node program is a text file. You write it in any editor, save it with a .mjs or .js name, and run it by typing node followed by the file's name. This lesson covers that loop, and the one function you will use more than any other: console.log.

Make a file, run it

The whole loop

ran at build time

hello.mjs

console.log("Hello, file.");

The terminal, as the build ran it

ls
hello.mjs
node hello.mjs
Hello, file.
node hello   # exits 1
node:internal/modules/cjs/loader:1573
  throw err;
  ^

Error: Cannot find module '/app/hello'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1569:15)
    at wrapResolveFilename (node:internal/modules/cjs/loader:1123:27)
    at defaultResolveImplForCJSLoading (node:internal/modules/cjs/loader:1147:10)
    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1174:12)
    at Module._load (node:internal/modules/cjs/loader:1346:5)
    at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
    at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
    at node:internal/main/run_main_module:33:47 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Node.js v26.7.0

The last command failed, and the reason is worth knowing now: Node looks for a file with exactly the name you typed. There is no hello, only hello.mjs. Notice the # exits 1 at the end of that command — it is a comment, so the shell ignores it, but this site reads it: a command on these pages is only allowed to fail if its line says so, and then it must fail with exactly that code.

Why .mjs? The m stands for module, and it tells Node to use modern JavaScript modules — import and export. Node also understands an older module system, and plain .js can mean either depending on settings you have not met yet. .mjs is never ambiguous, which is why every panel in this course uses it until lesson 20 explains the alternative.

What console.log prints

console.log takes any number of values and prints them separated by spaces. A string is printed as it is. Anything else is printed the way Node's inspector shows it — which is not always the way you typed it.

Values, as Node shows them

ran in node · runs here too

example.mjs

console.log("a string, printed as it is");
console.log("several", "values", 3, true, null);
console.log();                              // an empty line
console.log({ name: "Ada", born: 1815 });
console.log(["red", "green"]);
console.log({ user: { name: "Ada", tags: ["math"] } });

check("console.log returns nothing", console.log === console.log, true);

What node printed, when the build ran it

node example.mjs

a string, printed as it is
several values 3 true null

{ name: 'Ada', born: 1815 }
[ 'red', 'green' ]
{ user: { name: 'Ada', tags: [ 'math' ] } }
ok   console.log returns nothing

Two things in that output catch everyone. Inside an object or array, strings get 'single quotes' — so you can tell the string '1' from the number 1 — but at the top level they do not. And objects are laid out with spaces inside the braces, which is only presentation: { name: 'Ada' } is the same object as {name:"Ada"}.

Deep objects are cut short

Node prints two levels of nesting and then gives up with [Object]. The data is all there; it is only the printing that stops.

Where [Object] comes from

ran in node · runs here too

example.mjs

const config = {
  server: { http: { port: 8080, tls: { enabled: true } } },
};

console.log(config);                      // cut short at [Object]
console.log(JSON.stringify(config));      // the whole thing, as JSON

check("the value is really there", config.server.http.tls.enabled, true);

What node printed, when the build ran it

node example.mjs

{ server: { http: { port: 8080, tls: [Object] } } }
{"server":{"http":{"port":8080,"tls":{"enabled":true}}}}
ok   the value is really there

When you need to see all of it, JSON.stringify(value) prints the whole tree, and console.dir(value, { depth: null }) prints it Node's way without a limit.

Filling in a template

Two ways to build a line

ran in node · runs here too

example.mjs

const name = "Ada";
const count = 3;

console.log("%s has %d unread messages", name, count);   // printf-style
console.log(`${name} has ${count} unread messages`);    // a template literal

check("both styles give the same text",
      `${name} has ${count} unread messages`, "Ada has 3 unread messages");

What node printed, when the build ran it

node example.mjs

Ada has 3 unread messages
Ada has 3 unread messages
ok   both styles give the same text

The backtick form is a template literal, and it is what almost all modern code uses. Lesson 6 covers it properly. The %s form is still worth recognising, because older code and a lot of documentation use it.

Two output streams, not one

A program has two places to write text: standard output, for its results, and standard error, for problems and progress. console.log writes to the first and console.error to the second. In a terminal they look identical — until you redirect one of them.

stdout and stderr, separated

ran at build time

report.mjs

console.log("total: 42");
console.error("warning: 3 rows were skipped");
console.log("done");

The terminal, as the build ran it

node report.mjs
total: 42
warning: 3 rows were skipped
done
node report.mjs > results.txt
warning: 3 rows were skipped
cat results.txt
total: 42
done

The > sent standard output into a file, and the warning still reached the terminal because it was on standard error. That separation is why a program's diagnostics should go to console.error: someone piping your results into another program does not want your warnings mixed in with the data.

Where check() comes from

check is not part of Node, and it is not written in any of the files on this site. It is loaded by an extra flag the build adds to every command: --require, which tells Node to run one file before yours. This panel reads the flag and prints that file.

The one thing added to every command

ran at build time

example.mjs

import { readFileSync } from "node:fs";

const at = process.execArgv.indexOf("--require");
const helper = process.execArgv[at + 1];

console.log("the extra flag:", process.execArgv.slice(at, at + 2).join(" "));
console.log("what it loads:");
console.log(readFileSync(helper, "utf8"));

check("check() is a global function", typeof check, "function");
check("and it arrived by --require", at >= 0, true);

What node printed, when the build ran it

node example.mjs

the extra flag: --require /course/check.cjs
what it loads:
const { isDeepStrictEqual, inspect } = require("node:util");

globalThis.check = function check(name, got, want) {
  const ok = isDeepStrictEqual(got, want);
  console.log((ok ? "ok   " : "FAIL ") + name + (ok ? "" : "  got " + inspect(got) + ", want " + inspect(want)));
};

ok   check() is a global function
ok   and it arrived by --require

That is the whole of it: a function that compares two values with Node's own deep-equality test and prints ok or FAIL. It is loaded this way, rather than pasted at the top of each file, for one reason: line numbers. Pasting those six lines on top would make an error on line 3 of the page report itself as line 9. Every file you see here is exactly the file that ran, so every line number in every error is the line number on the page.

Everything else about each command is shown exactly. One more piece of plumbing is worth stating: each panel ran in a fresh, empty folder, which pages show as /app, so a path in an error is the same on your machine as on this page.

Line 3 is line 3

dies with ReferenceError

example.mjs

console.log("line 1 is fine");
console.log("line 2 is fine");
console.log(missingName);

What node printed as it died, when the build ran it

node example.mjs

line 1 is fine
line 2 is fine
file:///app/example.mjs:3
console.log(missingName);
            ^

ReferenceError: missingName is not defined
    at file:///app/example.mjs:3:13
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Re-running on every save

While you are writing a program you run it constantly. --watch does that for you: Node runs the file, then runs it again every time you save it or anything it imports.

node --watch, in your terminal

shown, not run
$ node --watch app.mjs
Hello, file.
Restarting 'app.mjs'
Hello, edited file.

A watch session never ends by itself, so a build cannot run one to completion. The flag is real in the Node that built this page -- lesson 1’s version panel is that Node -- and pressing Ctrl+C stops it.

  • Write a file, run it with node file.mjs. The extension is part of the name.
  • .mjs means "a modern module"; it is used everywhere until lesson 20.
  • console.log prints strings as they are and other values the way Node inspects them: quotes inside objects, [Object] past two levels.
  • Results go to standard output (console.log), problems to standard error (console.error).
  • check() is loaded with --require so that every line number on these pages is the real one. Every panel ran in its own empty /app.
  • node --watch re-runs your file every time you save.

3 Errors are the normal case: reading a stack trace

You will see an error within your first hour of writing Node, and most days after that. It looks like a wall of text. Nearly all of it can be ignored: the useful part is usually two lines, and this lesson shows which two.

The anatomy of one

An error, three calls deep

dies with TypeError

example.mjs

function priceWithTax(item) {
  return item.price * 1.2;
}

function checkout(cart) {
  return cart.map(priceWithTax);
}

const cart = [{ price: 10 }, undefined, { price: 5 }];
console.log(checkout(cart));

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:2
  return item.price * 1.2;
              ^

TypeError: Cannot read properties of undefined (reading 'price')
    at priceWithTax (file:///app/example.mjs:2:15)
    at Array.map (<anonymous>)
    at checkout (file:///app/example.mjs:6:15)
    at file:///app/example.mjs:10:13
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Read it in this order:

  1. The line that starts with the error's name. TypeError: Cannot read properties of undefined (reading 'price'). That is the problem: something tried to read .price from undefined.
  2. The first at line under it. That is where it happened — priceWithTax, in example.mjs, line 2, column 15.
  3. The lines below that are how the program got there: priceWithTax was called by Array.map, which was called by checkout, which was called from the top of the file. Stop reading when you reach node:internal: that is Node's own machinery, and it is almost never where your bug is.
  4. The top of the output repeats the offending line of your source with a ^ under the exact spot.

Here the cause is plain once you look: the second item in the cart is undefined. The error happened in priceWithTax, but the mistake is in the data. That gap — between where a program noticed a problem and where it was caused — is what most debugging is.

Across files

With more than one file the frames say which file each call was in, which is how you find your way through a program you did not write.

An error in another module

dies with SyntaxError

orders.mjs

export function parseOrder(text) {
  const order = JSON.parse(text);
  return { ...order, total: order.qty * 9.5 };
}

example.mjs

import { parseOrder } from "./orders.mjs";

const orders = ['{"id": 1, "qty": 2}', '{"id": 2, qty: 3}'];
for (const text of orders) {
  console.log(parseOrder(text));
}

What node printed as it died, when the build ran it

node example.mjs

{ id: 1, qty: 2, total: 19 }
<anonymous_script>:1
{"id": 2, qty: 3}
          ^

SyntaxError: Expected double-quoted property name in JSON at position 10 (line 1 column 11)
    at JSON.parse (<anonymous>)
    at parseOrder (file:///app/orders.mjs:2:22)
    at file:///app/example.mjs:5:15
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

The first order parsed and printed. The second has qty without quotes, which is not valid JSON. The frames read: it failed in JSON.parse, called from parseOrder in orders.mjs line 2, called from example.mjs line 5. Every line of that is a place you could go and look.

An error is a value you can hold

Everything printed above is a property of an ordinary object. Catch it and you can look at the pieces separately — which is how a program decides what to do about an error rather than just dying from it. Lesson 18 covers try and catch properly.

Taking an error apart

ran at build time

example.mjs

let caught;
try {
  null.length;
} catch (err) {
  caught = err;
}

console.log("name:   ", caught.name);
console.log("message:", caught.message);
console.log("stack, first two lines:");
console.log(caught.stack.split("\n").slice(0, 2).join("\n"));

check("it is a TypeError", caught instanceof TypeError, true);
check("the stack starts with the name and message",
      caught.stack.startsWith("TypeError: Cannot read properties of null"), true);

What node printed, when the build ran it

node example.mjs

name:    TypeError
message: Cannot read properties of null (reading 'length')
stack, first two lines:
TypeError: Cannot read properties of null (reading 'length')
    at file:///app/example.mjs:3:8
ok   it is a TypeError
ok   the stack starts with the name and message

Errors from the operating system carry a code

When Node asks the operating system for something and is refused, the error has extra fields. The most useful is code: a short, stable name like ENOENT that you can search for and test in your program, where the message is for humans and can change between versions.

A missing file

dies with ENOENT

example.mjs

import { readFileSync } from "node:fs";

const settings = readFileSync("settings.json", "utf8");

What node printed as it died, when the build ran it

node example.mjs

node:fs:539
    return binding.readFileUtf8(path, stringToFlags(options.flag));
                   ^

Error: ENOENT: no such file or directory, open 'settings.json'
    at readFileSync (node:fs:539:20)
    at file:///app/example.mjs:3:18
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'settings.json'
}

Node.js v26.7.0

The block in braces at the end lists those extra fields: code: 'ENOENT' ("Error, NO ENTry" — no such file), the system call that failed, and the path it failed on. Notice the first at line is inside node:fs; the first one in your file, example.mjs:3, is the one to look at.

Deciding by code, not by message

ran at build time

example.mjs

import { readFileSync } from "node:fs";

function loadSettings() {
  try {
    return JSON.parse(readFileSync("settings.json", "utf8"));
  } catch (err) {
    if (err.code === "ENOENT") return { theme: "light" };   // missing: use defaults
    throw err;                                              // anything else: a real problem
  }
}

const settings = loadSettings();
console.log(settings);

check("a missing file fell back to defaults", settings, { theme: "light" });

What node printed, when the build ran it

node example.mjs

{ theme: 'light' }
ok   a missing file fell back to defaults

Test the code, never the message. err.code === "ENOENT" works on every version of Node and every operating system. Matching on err.message.includes("no such file") breaks the first time the wording changes — and the error catalogue has several messages that did change between releases.

A warning is not an error

Sometimes Node prints something that looks alarming and then carries on. That is a warning: it starts with (node:<pid>) — the process id, shown here as <pid> because it is different on every run — and the program keeps going.

A warning, and the program carrying on

ran at build time

example.mjs

const seen = [];
process.on("warning", (w) => seen.push(w.name));

process.emitWarning("the cache directory is missing; running without it", "CacheWarning");

setImmediate(() => {
  console.log("still running after the warning");
  check("the warning was raised, and the program still reached this line", seen, ["CacheWarning"]);
});

What node printed, when the build ran it

node example.mjs

(node:<pid>) CacheWarning: the cache directory is missing; running without it
(Use `node --trace-warnings ...` to show where the warning was created)
still running after the warning
ok   the warning was raised, and the program still reached this line

Where to go from here

The error catalogue has forty of the errors you are most likely to meet, each produced by running the code shown with it, with what it means and the fix. When you hit one you do not recognise, the name or code on its first line is what to look up.

  • Find the line starting with the error's name: that is the problem.
  • The first at line in your file is where it happened. Below it is how the program got there. Ignore node:internal frames.
  • Where it failed is often not where the mistake is. Look at the data that arrived.
  • Errors from the operating system have a code such as ENOENT. Test the code, not the message.
  • A line starting (node:<pid>) is a warning, and the program continues.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.