Skip to main content
Node.js · lesson 1 of 20
Node.js beginner Lesson 1 of 20

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

nodejs runtime v8 libuv event-loop

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.

Frequently Asked Questions

Is Node.js a programming language?
No. Node.js is a JavaScript runtime: a C++ program that embeds Google's V8 engine (version 14.6.202.34-node.40 inside Node 26.13.0) and adds libuv for filesystem and network I/O. The language is the same JavaScript a browser runs; process.versions is the object that lists every one of these embedded components by name.
Why does document.title throw in Node.js but work in a browser?
document is a global the DOM API creates, and only a browser creates a DOM. Node.js never defines document, so referencing it throws ReferenceError: document is not defined — the same error class V8 throws for any undefined identifier, browser or not. Node has its own globals instead, such as process, that a browser never defines.
Does fs.readFileSync block the whole Node.js process?
It blocks the single JavaScript thread until the operating system returns the file's bytes. It does not stop an earlier fs.readFile call's own I/O, which libuv's thread pool keeps working on in the background — what it blocks is every pending callback, including that earlier readFile call's, because Node.js runs only one thread of JavaScript at a time and nothing can call that callback until the thread is free. That's why readFileSync's own console.log line always prints before that earlier callback's line does.
Why can a plain while loop delay a setTimeout(fn, 0) callback?
A timer's callback can only run once the call stack is empty, no matter how short its delay was — and Node clamps the delay itself to a minimum of 1 ms even when you pass 0, so '0 ms' already meant 'about 1 ms' before the loop ever ran. A tight while loop keeps the call stack busy the entire time it runs, so the callback waits for the loop to finish even though its (near-)0 ms delay was due immediately.
Where does the deeper microtask-vs-macrotask ordering get covered?
JavaScript Event Loop Explained (/javascript/event-loop/) traces that ordering line by line: process.nextTick, Promise microtasks, and setImmediate versus setTimeout, built on the same one-callback-at-a-time model a busy while loop delaying a 0 ms timer demonstrates here.