Node.js · lesson 1 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
What Node.js Is
See what is really inside Node.js, V8 and libuv, then watch a browser-only global fail, a sync read finish first, and a busy loop delay a 0 ms timer.
By SkillByExample · Published
Node.js is not a new language — it’s a C++ program that embeds Google’s V8 JavaScript engine and adds its own library, libuv, for the things V8 doesn’t do on its own: a thread pool for blocking work like filesystem calls, and hooks into the operating system’s own async I/O (epoll, kqueue, IOCP) for sockets. Timers still run on the same one JavaScript thread as everything else — libuv just tells the event loop when one is due. Every command below runs in Docker’s node:26.13 image, from /app.
What’s Actually Inside the Runtime
node -p evaluates one expression and prints it. process.versions is the plain object Node fills in with every embedded component’s own version string.
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node -p process.versions
{
node: '26.13.0',
acorn: '8.19.0',
ada: '4.0.0',
amaro: '1.1.11',
ares: '1.34.9',
brotli: '1.2.0',
cldr: '48.0',
icu: '78.3',
libffi: '3.8.0',
lief: '1.0.0',
llhttp: '9.4.3',
merve: '1.2.2',
modules: '147',
napi: '10',
nbytes: '0.1.4',
ncrypto: '0.0.1',
nghttp2: '1.70.0',
nghttp3: '',
ngtcp2: '',
openssl: '3.5.9',
simdjson: '5.0.3',
simdutf: '7.7.0',
sqlite: '3.54.0',
tz: '2026e',
undici: '8.12.2',
unicode: '17.0',
uv: '1.52.1',
uvwasi: '0.0.23',
v8: '14.6.202.34-node.40',
zlib: '1.3.2.1-motley-456ae73',
zstd: '1.5.7'
}
node: '26.13.0' is the Node.js release itself. v8: '14.6.202.34-node.40' is the same JavaScript engine Chrome ships. uv: '1.52.1' is libuv, the C library that turns a blocking OS call (read this file, open this socket) into an event Node’s single thread can pick up once it’s actually ready. None of this exists in a browser tab — a browser’s own JavaScript engine never hands a web page a process object at all.
There’s No document or window
Node runs the same JavaScript syntax a browser does, but it never creates a DOM, so code written for a browser fails the moment it touches a browser-only global.
// browser-global.js
console.log(document.title);
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node browser-global.js
/app/browser-global.js:2
console.log(document.title);
^
ReferenceError: document is not defined
at Object.<anonymous> (/app/browser-global.js:2:13)
at Module._compile (node:internal/modules/cjs/loader:1968:14)
at Object..js (node:internal/modules/cjs/loader:2108:10)
at Module.load (node:internal/modules/cjs/loader:1690:32)
at Module._load (node:internal/modules/cjs/loader:1480:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
at node:internal/main/run_main_module:33:47
Node.js v26.13.0
document is a global the DOM API creates; Node.js has no DOM, so it never defines one, and V8 throws a plain ReferenceError: document is not defined — the same error class it would throw for any other undefined identifier. The stack trace’s first frame points at this file itself, /app/browser-global.js:2:13, where document.title actually ran; every frame after that is inside node:internal/modules/cjs/loader, Node’s own module loader unwinding back out to where it started this script — because this file has no import/export syntax of its own, Node’s automatic module-syntax detection fell back to treating it as CommonJS.
A Synchronous Read Always Wins the Race
node:fs exports both a synchronous and a callback-based version of the same read, and the synchronous one blocks Node’s one thread until the operating system actually returns the bytes.
// sync-vs-async.js
import { readFile, readFileSync } from "node:fs";
console.log("1 - before any read");
const syncData = readFileSync("note.txt", "utf8");
console.log("2 - readFileSync returned:", syncData.trim());
readFile("note.txt", "utf8", (err, data) => {
console.log("4 - readFile callback:", data.trim());
});
console.log("3 - after starting readFile, before its callback");
$ echo "hello from disk" > note.txt
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node sync-vs-async.js
1 - before any read
2 - readFileSync returned: hello from disk
3 - after starting readFile, before its callback
4 - readFile callback: hello from disk
readFileSync doesn’t return control to the script until the file is fully read, so line 2 always prints before line 3. readFile only starts the read and returns immediately — its callback doesn’t run until later, after the current script finishes, which is why line 4 prints last even though readFile was called before line 3 ran. It’s not that readFileSync stalls the pending readFile call’s own I/O — libuv’s thread pool can still be reading that file in the background while readFileSync runs — it’s that nothing can run that callback until the one JavaScript thread both scripts share is free to take a turn.
A Busy Loop Can Delay Even a 0 ms Timer
setTimeout(fn, 0) schedules fn for “as soon as possible,” not “right now” — Node clamps anything under one millisecond up to one millisecond, so 0 already behaves like 1, and either way it still finishes whatever’s already on the call stack first. With nothing else running, that gap is small enough that this callback reports it as fast:
// timer-baseline.js
const start = Date.now();
setTimeout(() => {
const waited = Date.now() - start;
console.log(waited > 1000 ? "SLOW: timer waited more than 1000 ms" : "FAST: timer waited 1000 ms or less");
}, 0);
console.log("sync code done, nothing blocking the thread");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node timer-baseline.js
sync code done, nothing blocking the thread
FAST: timer waited 1000 ms or less
Now schedule the exact same timer, but keep the single JS thread busy in a loop for about 1.5 real seconds right after scheduling it:
// busy-timer.js
const start = Date.now();
setTimeout(() => {
const waited = Date.now() - start;
console.log(waited > 1000 ? "SLOW: timer waited more than 1000 ms" : "FAST: timer waited 1000 ms or less");
}, 0);
console.log("busy loop starting");
while (Date.now() - start < 1500) {
// block the one JS thread for about 1.5 real seconds
}
console.log("busy loop finished");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node busy-timer.js
busy loop starting
busy loop finished
SLOW: timer waited more than 1000 ms
Both scripts schedule the identical setTimeout(fn, 0). The only difference is whether anything keeps the call stack busy in between, and that difference alone turns FAST into SLOW: a timer, like every other callback, only gets a turn once the call stack is empty, and a while loop never empties it. This is the single-threaded model in miniature — JavaScript Event Loop Explained traces the same stack-then-microtasks-then-one-macrotask rule across process.nextTick, Promises, and setImmediate.
Every other lesson in this topic builds on what showed up here: one JavaScript thread running your code, and libuv handing it events once the OS work it asked for is actually done. The HTTP Module and Why Frameworks Exist starts that build by using node:http directly, with no framework yet.
Practice
1. process.versions printed whole includes keys this lesson never mentions, like acorn and ada. Print only the node, v8, and uv fields, one per line.
Solution
// versions-pick.js
console.log("node:", process.versions.node);
console.log("v8:", process.versions.v8);
console.log("uv (libuv):", process.versions.uv);
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node versions-pick.js
node: 26.13.0
v8: 14.6.202.34-node.40
uv (libuv): 1.52.1
Picking fields off the same object the first example printed whole just skips the ones this lesson doesn’t use — process.versions doesn’t change shape depending on how you read it.
2. Catch the ReferenceError from the document.title example instead of letting it crash the process, print its name and message, and prove the script keeps running afterward.
Solution
// catch-global.js
try {
console.log(document.title);
} catch (err) {
console.log(err.name + ": " + err.message);
}
console.log("still running after the catch");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node catch-global.js
ReferenceError: document is not defined
still running after the catch
err.message is exactly document is not defined — the text after the error class in the uncaught stack trace earlier. Wrapping the same line in try/catch turns the crash into two ordinary console.log lines, and the script reaches its last line instead of exiting.
3. Start the asynchronous readFile call before readFileSync this time, and check whether the synchronous read still finishes first.
Solution
// async-first.js
import { readFile, readFileSync } from "node:fs";
console.log("1 - before any read");
readFile("note.txt", "utf8", (err, data) => {
console.log("3 - readFile callback:", data.trim());
});
console.log("2 - readFile started, now calling readFileSync");
const syncData = readFileSync("note.txt", "utf8");
console.log("2.5 - readFileSync returned:", syncData.trim());
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node async-first.js
1 - before any read
2 - readFile started, now calling readFileSync
2.5 - readFileSync returned: hello from disk
3 - readFile callback: hello from disk
Yes — line 2.5 still prints before line 3, even though readFile was called first. readFile only registers a request with libuv and returns right away; its callback can’t run until the current script finishes and the call stack is empty, and readFileSync runs to completion before that script ever finishes. Calling it earlier changes nothing about when its callback actually gets a turn.
4. Lower busy-timer.js’s loop so it blocks for less than 1000 ms, and confirm the assertion flips back to FAST.
Solution
// short-busy-timer.js
const start = Date.now();
setTimeout(() => {
const waited = Date.now() - start;
console.log(waited > 1000 ? "SLOW: timer waited more than 1000 ms" : "FAST: timer waited 1000 ms or less");
console.log(waited >= 400 ? "DELAYED: still well past timer-baseline.js's instant callback" : "INSTANT");
}, 0);
console.log("busy loop starting");
while (Date.now() - start < 500) {
// block the one JS thread for about 0.5 real seconds, under the 1000 ms threshold
}
console.log("busy loop finished");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node short-busy-timer.js
busy loop starting
busy loop finished
FAST: timer waited 1000 ms or less
DELAYED: still well past timer-baseline.js's instant callback
The threshold assertion flips back to FAST once the loop blocks for less than the 1000 ms it checks against. DELAYED is the second, lower threshold this solution adds: it confirms in the output, not just in prose, that the loop still delayed the timer well past timer-baseline.js’s near-instant callback — it just didn’t delay it long enough to cross the 1000 ms line above.
5. Schedule two setTimeout(fn, 0) timers before the busy loop in busy-timer.js, and confirm both get delayed, not just the first one registered.
Solution
// two-timers.js
const start = Date.now();
function report(label) {
const waited = Date.now() - start;
console.log(`${label}: ` + (waited > 1000 ? "SLOW" : "FAST"));
}
setTimeout(() => report("timer A"), 0);
setTimeout(() => report("timer B"), 0);
console.log("busy loop starting");
while (Date.now() - start < 1500) {
// block the one JS thread; both timers above are already due long before this ends
}
console.log("busy loop finished");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node two-timers.js
busy loop starting
busy loop finished
timer A: SLOW
timer B: SLOW
Both timer A and timer B report SLOW. The loop doesn’t single out the first timer it finds due — it keeps the call stack occupied for every callback waiting behind it, so every timer that was due before the loop started has to wait for the exact same stretch of blocked time.