Node.js From Zero

What Node.js Is and How It Runs Your Code

V8, libuv and the runtime, the single thread and what it means, modules (ESM and CommonJS), npm, and running your first script.

Lesson 0012 min read#nodejs#runtime#modules#beginner

On this page

  1. Words used in this lesson
  2. What Node.js is, in pictures
  3. Running code
  4. One thread, but not alone
  5. The big idea: do not wait, ask to be told
  6. Modules: ESM and CommonJS
  7. npm: installing code other people wrote
  8. What you get without installing anything
  9. Why the single thread is a good trade (and where it hurts)
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: Node.js is a program that runs JavaScript outside the browser. It is built from the V8 engine (which runs your JavaScript), a C library called libuv (which does the waiting for files and networks), and a set of built-in modules. Your code runs on one thread, one thing at a time, and that single fact explains most of how Node behaves.

You may already know JavaScript from the browser. Node uses the same language, but the surroundings are different. There is no page, no window, no document. Instead you get files, sockets, processes and the operating system. People use Node for web servers, command line tools, build tools (Next.js, Vite and many more run on it) and scripts.

This is lesson 0 of a ten-lesson series. You do not need to know Node. You need basic JavaScript: variables, functions, async and await. If those feel shaky, read Async JS and the Event Loop first. In this lesson we look inside the runtime, run real scripts, and learn the two module systems and npm. The next lesson, The Event Loop, goes deep into the one thing this lesson only introduces: how a single thread can still handle thousands of connections.

What I ran / checked. Everything in this lesson ran on Linux (4 CPU cores) with Node.js v22.22.0 (V8 12.4.254.21-node.33, libuv 1.51.0) and npm 10.9.4. The output blocks are real pasted output. Timings differ on every machine, so treat the numbers as examples. Sources I read (all facts below are in my own words): the Node.js API docs for process, timers and http (the raw markdown in the nodejs/node repository, v22.x branch), the libuv "Design overview" page from the libuv repository, the official Node.js learn guide "The Node.js Event Loop" (nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick), and the README of goldbergyoni/nodebestpractices. Could not read: the raw markdown path for the learn guide on nodejs/nodejs.org returned 404 from my machine, so I read the rendered page instead. I did not check the Node release schedule, so I do not state which release line is "LTS" today; check nodejs.org/en/about/previous-releases before you pick a version for production. I did not verify any GitHub star counts, so I give none.

Words used in this lesson

  • Runtime is the program that runs your code and gives it tools (files, network, timers). Node.js is a JavaScript runtime. The browser is another one.
  • V8 is the JavaScript engine made by Google. It turns your JavaScript into machine code and runs it. Chrome uses it too.
  • libuv is a C library that Node uses for the event loop and for talking to the operating system in a way that works on Linux, macOS and Windows.
  • Thread is one line of execution inside a program. A program can have many threads.
  • Single-threaded means your JavaScript code runs on one thread, so only one piece of it runs at a time.
  • Blocking code keeps the thread busy, so nothing else can run until it finishes. Non-blocking code starts a job and lets the thread do other work while it waits.
  • Event loop is the loop inside Node that waits for things to finish (a file read, a timer, a network message) and then runs your callback. Lesson 1 covers it in detail.
  • Callback is a function you give to Node and say "call this when the job is done".
  • Module is a JavaScript file that other files can load. ESM (ES modules) uses import and export. CommonJS (CJS) uses require and module.exports.
  • npm is the package manager that comes with Node. A package is code someone else wrote that you install with npm.
  • package.json is the file that describes your project: its name, scripts and dependencies.
  • Exit code is a number a program returns when it ends. 0 means success. Anything else means failure.
  • Built-in module is a module that ships with Node, such as node:fs or node:http.

What Node.js is, in pictures

Think of Node as three layers.

TEXT
 your JavaScript code +-------------------------------------------------+ | Node.js built-in modules (fs, http, path, ...)  |   JavaScript + C++ +-------------------------------------------------+ | V8                    |  libuv                  | | runs JavaScript       |  event loop, thread     | |                       |  pool, sockets, files   | +-------------------------------------------------+ | operating system (Linux, macOS, Windows)        | +-------------------------------------------------+
  • V8 runs your code, line by line, very fast. V8 knows nothing about files or networks. It only knows the language.
  • libuv handles the outside world. When you ask for a file, libuv does the waiting. When the file is ready, libuv tells Node, and Node calls your callback.
  • Built-in modules are the friendly names on top. When you write fs.readFile(...), a mix of JavaScript and C++ code in Node passes the request down to libuv.

The libuv design page describes it like this: the library is built around an event-driven, asynchronous I/O model. Network input and output use non-blocking sockets that are watched with the best tool the operating system offers (epoll on Linux). File work is different, and we come to it below.

You can ask Node for the versions it was built with:

JavaScript
console.log("node  ", process.version);console.log("v8    ", process.versions.v8);console.log("libuv ", process.versions.uv);console.log("openssl", process.versions.openssl);console.log("platform", process.platform, process.arch);console.log("typeof window:", typeof window, "| typeof globalThis.fetch:", typeof fetch);console.log("args:", process.argv.slice(2));
TEXT
$ node versions.mjs hello --name=adanode   v22.22.0v8     12.4.254.21-node.33libuv  1.51.0openssl 3.5.4platform linux x64typeof window: undefined | typeof globalThis.fetch: functionargs: [ 'hello', '--name=ada' ]

Three things to notice. First, process is a global object that Node gives you. It describes the running program. Second, window does not exist, but fetch does (Node has had a built-in fetch for a while). Third, process.argv holds the words you typed after the file name. The first two entries of the full array are the path to node and the path to your script, so we cut them off with slice(2).

Running code

There are four ways to run JavaScript with Node. You will use the first one most.

Shell
node app.js          # run a filenode -e 'console.log(1 + 1)'   # run a string of code (-e is "evaluate")node -p '1 + 1'      # evaluate and print the result (-p is "print")node                 # no arguments: starts the REPL, a place to type code line by line

The REPL ("read, evaluate, print, loop") is good for quick tests. Type .exit or press Ctrl+D to leave.

A first real script: count words in a file

Here is a small tool, wc.mjs. It reads the file name from the command line and prints lines, words and bytes.

JavaScript
import { readFile } from "node:fs/promises";const [file] = process.argv.slice(2);if (!file) {  console.error("usage: node wc.mjs <file>");  process.exit(2);}try {  const text = await readFile(file, "utf8");  const words = text.split(/\s+/).filter(Boolean).length;  const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);  console.log(`${file}: ${lines} lines, ${words} words, ${Buffer.byteLength(text)} bytes`);} catch (err) {  console.error(`cannot read ${file}: ${err.code}`);  process.exit(1);}

I made sample.txt with two lines of text and ran it three ways:

TEXT
$ node wc.mjs sample.txt; echo "exit=$?"sample.txt: 2 lines, 9 words, 44 bytesexit=0$ node wc.mjs nope.txt; echo "exit=$?"cannot read nope.txt: ENOENTexit=1$ node wc.mjs; echo "exit=$?"usage: node wc.mjs <file>exit=2

Notes on this small script:

  • node:fs/promises is a built-in module. The node: prefix says "this one comes with Node, do not look in node_modules". It is a good habit, because it cannot be confused with an npm package of the same name.
  • await at the top of the file works because this is an ES module (more on that below).
  • console.error writes to a different output stream (stderr) than console.log (stdout). Tools that pipe your output only capture stdout. Errors and usage messages belong on stderr.
  • process.exit(1) ends the program with exit code 1. Shell scripts and CI systems check this number. If your tool fails, return non-zero. If you do nothing, Node exits with 0.
  • ENOENT is the code for "no such file or directory". Node errors carry a code string that is stable, so you check err.code and not the message text.

Prefer process.exitCode = 1 over process.exit(1) when other work is still running. process.exit() stops the program right now, even if a file write or a network call is half done. Setting process.exitCode lets Node finish its pending work and then exit with that code. I checked: node -e 'process.exitCode = 3'; echo $? printed 3.

An uncaught error also ends the program. Node prints the stack trace and exits with code 1:

TEXT
$ node -e 'throw new Error("boom")'[eval]:1throw new Error("boom")^Error: boom    at [eval]:1:7exit=1

One thread, but not alone

The most repeated sentence about Node is "Node is single-threaded". It is true and also misleading, so let us look at the facts. I wrote a script that lists the operating system threads of its own process by reading /proc/self/task (this works on Linux only):

JavaScript
import { readdirSync, readFileSync } from "node:fs";import { setTimeout as sleep } from "node:timers/promises";import { readFile } from "node:fs/promises";const count = () => readdirSync("/proc/self/task").length;const names = () => readdirSync("/proc/self/task")  .map(t => readFileSync(`/proc/self/task/${t}/comm`, "utf8").trim());console.log("OS threads at start:", count());console.log(names().join(", "));await Promise.all([1, 2, 3, 4, 5, 6].map(() => readFile("/etc/passwd")));await sleep(50);console.log("after some fs work:", count());console.log(names().join(", "));
TEXT
OS threads at start: 7node, DelayedTaskSche, node, node, node, node, nodeafter some fs work: 11node, DelayedTaskSche, node, node, node, node, node, libuv-worker, libuv-worker, libuv-worker, libuv-worker

So a "single-threaded" Node process has 7 threads before it does anything. Some belong to V8 (for example, background work such as garbage collection helpers), and one thread runs your JavaScript. After the script read some files, four more threads appeared, named libuv-worker. These form libuv's thread pool.

Here is the correct way to say it:

  • Your JavaScript runs on one thread. Only one function of yours runs at a time. There is no way for two of your functions to change the same variable at the same moment, so you do not need locks. This is a big simplification.
  • Node itself uses other threads for jobs that cannot be done without waiting. File system calls, DNS lookups (dns.lookup), some crypto functions and compression run on the libuv thread pool. The libuv docs list file system operations, DNS lookups and user work queued to the pool as the three kinds of work it handles. Network input and output do not use the pool. They run on the main thread with non-blocking sockets, which is why one Node process can hold many open connections cheaply.

The idea is easy to picture. You are a cook with one pair of hands. You start the soup, set a timer, and while the soup heats you chop onions. You never stand and stare at the pot. The "waiting" is done by the stove (the operating system) and by helpers (the thread pool). But if you start chopping a huge mountain of onions and do not look up for ten minutes, the soup burns, and every other order waits too. That is a blocked event loop, and we will measure it in lesson 1.

The thread pool is small, on purpose

The pool has 4 threads by default. You can see what that means with a job that uses the pool: pbkdf2, a password hashing function that takes a few hundred milliseconds of CPU time. I started 4 jobs at once, then 8, and then 8 with a pool of size 8 (set with the UV_THREADPOOL_SIZE environment variable). My machine has 4 cores.

JavaScript
import { pbkdf2 } from "node:crypto";const n = Number(process.argv[2]);const t0 = performance.now();let done = 0;for (let i = 0; i < n; i++) {  pbkdf2("pw", "salt", 600000, 64, "sha512", () => {    console.log(`job ${i} done at ${Math.round(performance.now() - t0)} ms`);    if (++done === n) console.log("all", n, "finished");  });}
TEXT
$ node pool.mjs 4job 1 done at 334 msjob 2 done at 396 msjob 3 done at 416 msjob 0 done at 426 msall 4 finished$ node pool.mjs 8job 1 done at 436 msjob 3 done at 532 msjob 2 done at 677 msjob 0 done at 677 msjob 4 done at 866 msjob 6 done at 986 msjob 5 done at 1032 msjob 7 done at 1072 msall 8 finished$ UV_THREADPOOL_SIZE=8 node pool.mjs 8job 0 done at 444 msjob 6 done at 513 msjob 2 done at 579 msjob 4 done at 625 msjob 3 done at 645 msjob 1 done at 646 msjob 7 done at 657 msjob 5 done at 661 msall 8 finished

(The order of jobs that finish close together changes from run to run. Only the overall shape matters.)

Read it like this. Four jobs finished together at about 330 to 430 ms, because the pool had four threads. Eight jobs with a pool of four finished in two waves: the first four around 430 to 680 ms, and the rest around 870 to 1070 ms. With a pool of 8 all eight finished at about 660 ms. Jobs waited in line because there were not enough threads.

Why this matters in real life: if your server hashes passwords with the async crypto calls and reads many files, they all share this one small pool. Remember it exists. Do not raise the number without measuring, because more threads than CPU cores does not make CPU work faster (the 8-job run above did not finish twice as fast as the 4-job run per job on my 4-core machine; it simply stopped waiting in line).

The big idea: do not wait, ask to be told

Compare two styles for reading a file.

JavaScript
import fs from "node:fs";import fsp from "node:fs/promises";console.log("sync     :", fs.readFileSync("/etc/hostname", "utf8").trim());          // 1. blocksfs.readFile("/etc/hostname", "utf8", (err, text) => console.log("callback :", text.trim())); // 2. callbackconsole.log("promise  :", (await fsp.readFile("/etc/hostname", "utf8")).trim());     // 3. promise
TEXT
sync     : vmcallback : vmpromise  : vm

All three read the same file. The difference is what the JavaScript thread does while it waits:

  1. readFileSync holds the thread until the file is read. Nothing else can run. For a command line script that is fine and often simplest. In a server it is dangerous, because every other request waits.
  2. readFile with a callback hands the job to libuv and returns at once. Your thread is free. When the file is ready, Node calls your function.
  3. fsp.readFile (from node:fs/promises) does the same as 2 but returns a promise, so you can use await. This is the style you will mostly use.

Rule of thumb: in code that runs more than once (a server, a loop over many files), use the async forms. In a startup script that runs once, sync calls are fine.

Modules: ESM and CommonJS

Real programs have many files. A module is a file that exports some things and imports others. Node has two systems because it is old: CommonJS existed first (2009), and ES modules were added to the language later and then to Node.

ES modules (ESM): import and export

JavaScript
// math.jsexport const add = (a, b) => a + b;export default function double(x) { return x * 2; }
JavaScript
// main.jsimport double, { add } from "./math.js";import { readFile } from "node:fs/promises";console.log(add(2, 3), double(21));console.log("dirname:", import.meta.dirname);console.log("filename:", import.meta.filename);console.log("__dirname is", typeof __dirname);const text = await readFile(new URL("./math.js", import.meta.url), "utf8");   // top-level awaitconsole.log("math.js has", text.length, "characters");
TEXT
$ node main.js5 42dirname: /tmp/lab-nodejs/l0/modfilename: /tmp/lab-nodejs/l0/mod/main.js__dirname is undefinedmath.js has 88 characters

What to learn from this:

  • A file with export has a named export (add) and a default export (double). You import named ones with braces, the default one without.
  • In ESM there is no __dirname or __filename. Instead, import.meta.dirname and import.meta.filename give the folder and the file (they exist on the Node version I ran; old tutorials use fileURLToPath(import.meta.url) and that still works).
  • Top-level await works: you can use await outside a function. This is only possible in ES modules.
  • You must write the file extension in relative imports: ./math.js, not ./math. I tested the forgiving form and Node refuses:
TEXT
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/lab-nodejs/l0/mod/math' imported from /tmp/lab-nodejs/l0/mod/noext.js

CommonJS (CJS): require and module.exports

JavaScript
// lib.cjsexports.add = (a, b) => a + b;console.log("lib.cjs loaded");
JavaScript
// cjs.cjsconst { add } = require("./lib.cjs");console.log(add(2, 3));console.log("__dirname:", __dirname);console.log("require is", typeof require, "| module.exports is", typeof module.exports);
TEXT
$ node cjs.cjslib.cjs loaded5__dirname: /tmp/lab-nodejs/l0/modrequire is function | module.exports is object

In CommonJS, require("./lib.cjs") runs the file at that moment, synchronously, and gives you whatever it put on module.exports. You do not need the extension for .js files, and __dirname and __filename exist.

How does Node decide which one a file is?

This confuses almost everyone once. The rules are short:

FileTreated as
.mjsalways ESM
.cjsalways CommonJS
.jsESM if the nearest package.json has "type": "module", otherwise CommonJS

So the usual setup for a new project is to put "type": "module" in package.json and write ESM in .js files. Then if you try to use require in such a file, you get a helpful error:

TEXT
$ node bad.js     (bad.js contains: const fs = require("node:fs");)file:///tmp/lab-nodejs/l0/mod/bad.js:1const fs = require("node:fs");           ^ReferenceError: require is not defined in ES module scope, you can use import insteadThis file is being treated as an ES module because it has a '.js' file extension and '/tmp/lab-nodejs/l0/mod/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.

Code you type with node -e is treated as CommonJS by default. In my test typeof require inside node -e was function. So a copied snippet can behave differently in -e and in a .mjs file. When something works in one place and not in the other, check which module system the file is in.

Mixing the two

  • ESM can import CommonJS. I ran import lib from "./lib.cjs"; import { add } from "./lib.cjs"; and both forms worked (2 4 was printed). The whole module.exports becomes the default import, and Node also tries to detect named exports.
  • CommonJS can load ESM with require() in the Node version I ran. My req-esm.cjs did const m = require("./math.js") for the ESM file above and printed 3 8 with no warning. I am telling you only what I saw on v22.22.0. Older Node 22 releases and Node 20 do not allow it, and a file that uses top-level await cannot be loaded this way. If you must support old versions, use dynamic import(), which works from CommonJS and returns a promise.

Modules run once

A module is loaded and run one time, and every file that imports it gets the same copy. I tested this:

JavaScript
// counter.jslet n = 0; export const inc = () => ++n;
JavaScript
// cache.jsimport { inc } from "./counter.js";import { inc as inc2 } from "./counter.js";console.log(inc(), inc2(), inc());
TEXT
$ node cache.js1 2 3

Both imports share one n. This is why a module can hold shared state like a database connection. It also means you should not put state in a module if you want a fresh copy each time.

npm: installing code other people wrote

npm comes with Node. It has three jobs: a huge public registry of packages, a command line tool, and a convention (package.json) for describing a project.

Start a project:

Shell
mkdir demo && cd demonpm init -y            # creates package.jsonnpm install ms         # downloads the package "ms" into node_modules
TEXT
added 1 package, and audited 2 packages in 433msfound 0 vulnerabilities

Now package.json has a new section, and a package-lock.json file appeared:

JavaScript
{  "name": "demo",  "version": "1.0.0",  "type": "module",  "scripts": {    "start": "node main.js",    "hi": "node -e \"console.log('hi from npm run')\""  },  "dependencies": {    "ms": "^2.1.3"  }}
JavaScript
import ms from "ms";console.log(ms("2 days"), ms(60000));
TEXT
$ node usems.js172800000 1m

Things to understand:

  • dependencies lists packages your program needs to run. devDependencies (install with npm install -D name) lists tools you only need while developing, like a test runner.
  • "^2.1.3" is a version range. The caret means "2.1.3 or any newer 2.x.y". Versions follow semver (major.minor.patch): a major number change means breaking changes.
  • package-lock.json records the exact versions that were installed, so your teammate and your server get the same code. Commit this file to git. In CI and in Docker builds, use npm ci, which installs exactly what the lock file says and fails if it does not match package.json.
  • node_modules holds the downloaded code. Never commit it. Add it to .gitignore.
  • Scripts are shortcuts. npm run hi runs the command in "hi". npm start is a shortcut for npm run start. Before the command runs, npm prints it (the > demo@1.0.0 hi lines):
TEXT
$ npm run hi> demo@1.0.0 hi> node -e "console.log('hi from npm run')"hi from npm run
  • npm view ms version asks the registry for the latest version (it printed 2.1.3 for me).

Every package you install runs with the full power of your user account. A package can read your files, your environment variables and your network. Install packages deliberately, prefer well-known ones, and read the security series lesson on dependencies before you add forty of them. The node_modules folder of a small project can hold hundreds of packages, because your packages have their own dependencies.

Two flags worth knowing

Node has grown tools that used to need npm packages. Two that I ran:

Shell
node --watch w.js                      # restart the script when a file it uses changesnode --env-file=.env -e 'console.log(process.env.GREETING)'   # load variables from a .env file

For --watch, I started a script, appended a comment to the file, and Node reacted:

TEXT
run at 55Completed running 'w.js'. Waiting for file changes before restarting...Restarting 'w.js'run at 56Completed running 'w.js'. Waiting for file changes before restarting...

For --env-file, my .env file contained GREETING=hello, and the command printed hello. Environment variables are how you give a program its settings (ports, keys, URLs) without hard-coding them. They are always strings: process.env.PORT is "3000", not 3000.

What you get without installing anything

Node ships a lot. Here is a short map of the built-in modules we use in this series:

ModuleWhat it is for
node:fs, node:fs/promisesFiles and folders (lesson 2)
node:pathBuilding and cleaning file paths (lesson 2)
node:stream, node:zlibStreams and compression (lesson 3)
node:httpServers and clients (lesson 4)
node:eventsThe EventEmitter class, used by almost everything
node:cryptoHashing, random numbers, encryption
node:os, node:processInformation about the machine and the running program
node:test, node:assertA test runner (lesson 8)
node:worker_threadsReal extra threads for CPU work (lesson 7)

Global names without any import: process, console, Buffer, setTimeout and friends, URL, fetch, AbortController, structuredClone, TextEncoder. I checked in node -e: structuredClone, AbortController, URL and setTimeout are all functions. There is no window and no document. Browser-only things like localStorage and the DOM do not exist.

Why the single thread is a good trade (and where it hurts)

Let us finish with the idea you must keep for the whole series.

Good side. A server spends most of its time waiting: for the database, for the disk, for the network. A single thread that never waits can serve a lot of connections, with low memory use, and with no locking problems.

Bad side. Anything that keeps the thread busy for a long time stops everything. A big loop, a huge JSON.parse, a slow regular expression, a readFileSync of a large file, hashing with a sync function. While that runs, no other request is handled, no timer fires. Lesson 1 measures this with real numbers. The nodebestpractices guide lists "don't block the event loop" as a rule and mentions that a bad regular expression on user input can freeze a server for seconds.

Hold two rules in your head:

  1. I/O work (waiting) is cheap in Node. Use async calls and let many things wait together.
  2. CPU work (thinking) is expensive in Node. Keep it short, or move it to a worker thread (lesson 7).

Try it

Do these in an empty folder. Each should take a few minutes.

  1. Run node -p "process.versions.node" and node -p "process.versions.v8". Write down both.
  2. Create hello.mjs that prints your name from process.argv (for example node hello.mjs Ada prints Hello, Ada). If no name is given, print a usage line to stderr and exit with code 2.
  3. Run npm init -y, then add "type": "module" to package.json. Make util.js that exports a function shout(s) returning s.toUpperCase() + "!", and main.js that imports and prints it. Run it.
  4. In the same folder, rename util.js to util.cjs and change it to CommonJS. Try to import it from main.js. What works, and what do you have to change?
  5. Run this and explain the order of the three lines: console.log("a"); setTimeout(() => console.log("b"), 0); console.log("c");
  6. Run node -e 'console.log(typeof require)' and then put the same line in a .mjs file. Why do they differ?

Answers

  1. Your two version strings will differ from mine. For v22.22.0 the V8 version was 12.4.254.21-node.33.
  2. A solution:
    JavaScript
    const name = process.argv[2];if (!name) { console.error("usage: node hello.mjs <name>"); process.exit(2); }console.log(`Hello, ${name}`);
  3. main.js has import { shout } from "./util.js"; console.log(shout("hi")); and util.js has export const shout = (s) => s.toUpperCase() + "!";. It prints HI!.
  4. import { shout } from "./util.cjs" works if util.cjs uses exports.shout = ...; you must also change the file name in the import. Named import detection usually finds shout. If it does not, use the default import and read .shout from it.
  5. It prints a, c, b. The timer callback is only run after the current synchronous code is finished, even with a delay of 0. Lesson 1 shows exactly why.
  6. In -e code and in .cjs files, require exists (CommonJS). In .mjs files it is undefined because ESM has no require.

Interview questions

What is Node.js, and is it a language, a framework or something else? (Must know)

It is a runtime: a program that runs JavaScript outside the browser. It is not a language (the language is JavaScript) and not a framework (Express and Fastify are frameworks that run on it). Under the hood it combines the V8 engine, which runs the JavaScript, with libuv, which handles the event loop and talks to the operating system, plus a set of built-in modules like fs and http. I would add that Node adds server tools (files, sockets, processes) and removes browser tools (the DOM, window).

Node is "single-threaded". Is that really true? (Must know)

My JavaScript code runs on one thread, so one callback runs at a time and I never need locks around my variables. But the Node process itself has more threads. I looked at one and saw seven threads before any work, and four libuv-worker threads appeared after file reads. That pool handles file system calls, DNS lookups through dns.lookup, and some crypto and zlib work. Network input and output use non-blocking sockets on the main thread. So the accurate answer is: one thread for my JavaScript, a small thread pool for some work behind the scenes, and extra threads only if I ask for worker_threads.

How can a single thread handle many connections at once? (Must know)

Because most of the time a server is waiting, not working. Node asks the operating system to watch many sockets together (epoll on Linux, through libuv). The thread does not sit on one connection. It runs a callback when data arrives, finishes it quickly, and returns to the loop. Waiting is free, so thousands of idle connections cost very little. The condition is that each callback is short. If one callback runs for two seconds, every connection waits for those two seconds.

What is the difference between CommonJS and ES modules? (Must know)

CommonJS uses require and module.exports. require is a normal function that runs the file synchronously at that line, and __dirname and __filename exist. ES modules use import and export, which are part of the language. They are loaded in a way that allows top-level await, they need explicit file extensions in relative imports, and they have no __dirname (I use import.meta.dirname or import.meta.url). Node picks the system from the extension (.mjs or .cjs) or from the "type" field in the nearest package.json for .js files. For new code I use ESM.

A .js file says "require is not defined in ES module scope". What happened and how do you fix it? (Trap)

The file is being treated as an ES module, usually because package.json has "type": "module". Fix options: change the code to import, rename the file to .cjs so it stays CommonJS, or for one special case create a require with createRequire(import.meta.url) from node:module. The wrong fix is to remove "type": "module" blindly, because other files may rely on it.

Why does fs.readFileSync get a bad reputation on servers? (Must know)

Because it holds the only JavaScript thread until the file is fully read. During that time no other request is served and no timer fires. In a test I read a 300 MB file with readFileSync and a 10 ms heartbeat timer was late by about 1.3 seconds. The async readFile of the same file took about 0.4 seconds (the file was already in the operating system cache by then, so this is not a fair speed race) and the heartbeat was late by only about 1 ms. Sync calls are fine at startup, once, in scripts. In request handlers, I use the promise versions.

What does the node: prefix in import fs from "node:fs" do? (Must know)

It tells Node that this is a built-in module, so it never searches node_modules for a package with that name. It makes the intent clear to the reader and avoids a mix-up with an npm package that has the same name as a built-in one. Some newer modules, such as node:test, can only be loaded with the prefix.

What is the difference between dependencies and devDependencies, and why commit package-lock.json? (Must know)

dependencies are packages the program needs at run time. devDependencies are tools needed only while developing or building, such as a test runner or a linter. The lock file records the exact version of every package, including the dependencies of dependencies, so every machine installs the same code. Without it, a ^ range can pull a newer version on the server than the one I tested. I commit the lock file and use npm ci in CI and Docker builds.

Why is crypto.pbkdf2 with a callback sometimes slow when many users log in at once? (Senior signal)

Async pbkdf2 runs on libuv's thread pool, which has four threads by default. My test started eight jobs on a four-thread pool, and they finished in two waves (the first four near 430 to 680 ms, the last four near 870 to 1070 ms). The fifth job had to wait for a free thread. The same pool is shared with file system calls and dns.lookup, so a burst of hashing can also delay file reads. I can raise UV_THREADPOOL_SIZE, but not beyond what the CPU cores can use. A better fix for heavy login traffic is to rate-limit logins, or move hashing to separate worker processes.

What is the difference between process.exit() and process.exitCode? (Trap)

process.exit(n) stops the process immediately, even if there are pending writes or timers, so output can be cut off. Setting process.exitCode = n records the code but lets the event loop finish its pending work, and then Node exits with that code. I use exitCode in normal flow and reserve process.exit for "stop now" situations. Both set the code that shells and CI read: 0 is success and anything else is failure.

You are asked why a Node program does not exit by itself. What could be the reason? (Senior signal)

Node exits when the event loop has nothing left to wait for. If it keeps running, something still holds the loop open: an open server or socket, a setInterval that was never cleared, an open database pool, a file watcher. In lesson 1 I show that calling .unref() on a timer tells Node not to wait for it. To find the cause I check what I started, and in a debugging session I can list active handles. I do not fix it with process.exit() unless I have first closed things cleanly.

Key takeaways

  • Node.js is a runtime: V8 runs your JavaScript, libuv does the waiting, and built-in modules give you files, networks and more.
  • Your JavaScript runs on one thread. Node has other threads (a pool of 4 by default) for file system, DNS and some crypto work. Network I/O does not use the pool.
  • Waiting is cheap, thinking is expensive. Use async calls for I/O and keep each callback short.
  • process.argv, process.env, process.exitCode and process.exit() are how a script talks to the shell. Exit code 0 means success.
  • ESM (import) and CommonJS (require) are decided by the file extension and the "type" field. ESM needs file extensions, supports top-level await and has import.meta.dirname.
  • Use the node: prefix for built-ins. Commit package-lock.json. Use npm ci in CI. Packages run with your full power, so install with care.
  • node --watch and node --env-file replace small tools you might have installed in the past.

What's next

Next: The Event Loop, Lesson 1. You will see the phases of the loop, why a promise callback runs before a setTimeout(..., 0), what process.nextTick really does, and you will freeze a timer on purpose and measure it. If you want to practise first, run the order puzzle from Try it #5 and guess what happens when you add a setImmediate and a promise to it.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series