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.
On this page
- Words used in this lesson
- What Node.js is, in pictures
- Running code
- One thread, but not alone
- The big idea: do not wait, ask to be told
- Modules: ESM and CommonJS
- npm: installing code other people wrote
- What you get without installing anything
- Why the single thread is a good trade (and where it hurts)
- Try it
- Interview questions
- Key takeaways
- 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
importandexport. CommonJS (CJS) usesrequireandmodule.exports. - npm is the package manager that comes with Node. A package is code someone else wrote that you install with npm.
package.jsonis the file that describes your project: its name, scripts and dependencies.- Exit code is a number a program returns when it ends.
0means success. Anything else means failure. - Built-in module is a module that ships with Node, such as
node:fsornode:http.
What Node.js is, in pictures
Think of Node as three layers.
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:
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));$ 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.
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 lineThe 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.
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:
$ 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=2Notes on this small script:
node:fs/promisesis a built-in module. Thenode:prefix says "this one comes with Node, do not look innode_modules". It is a good habit, because it cannot be confused with an npm package of the same name.awaitat the top of the file works because this is an ES module (more on that below).console.errorwrites to a different output stream (stderr) thanconsole.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 with0.ENOENTis the code for "no such file or directory". Node errors carry acodestring that is stable, so you checkerr.codeand 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:
$ node -e 'throw new Error("boom")'[eval]:1throw new Error("boom")^Error: boom at [eval]:1:7exit=1One 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):
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(", "));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-workerSo 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.
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"); });}$ 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.
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. promisesync : vmcallback : vmpromise : vmAll three read the same file. The difference is what the JavaScript thread does while it waits:
readFileSyncholds 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.readFilewith a callback hands the job to libuv and returns at once. Your thread is free. When the file is ready, Node calls your function.fsp.readFile(fromnode:fs/promises) does the same as 2 but returns a promise, so you can useawait. 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
// math.jsexport const add = (a, b) => a + b;export default function double(x) { return x * 2; }// 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");$ node main.js5 42dirname: /tmp/lab-nodejs/l0/modfilename: /tmp/lab-nodejs/l0/mod/main.js__dirname is undefinedmath.js has 88 charactersWhat to learn from this:
- A file with
exporthas a named export (add) and a default export (double). You import named ones with braces, the default one without. - In ESM there is no
__dirnameor__filename. Instead,import.meta.dirnameandimport.meta.filenamegive the folder and the file (they exist on the Node version I ran; old tutorials usefileURLToPath(import.meta.url)and that still works). - Top-level
awaitworks: you can useawaitoutside 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:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/lab-nodejs/l0/mod/math' imported from /tmp/lab-nodejs/l0/mod/noext.jsCommonJS (CJS): require and module.exports
// lib.cjsexports.add = (a, b) => a + b;console.log("lib.cjs loaded");// 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);$ node cjs.cjslib.cjs loaded5__dirname: /tmp/lab-nodejs/l0/modrequire is function | module.exports is objectIn 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:
| File | Treated as |
|---|---|
.mjs | always ESM |
.cjs | always CommonJS |
.js | ESM 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:
$ 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 4was printed). The wholemodule.exportsbecomes the default import, and Node also tries to detect named exports. - CommonJS can load ESM with
require()in the Node version I ran. Myreq-esm.cjsdidconst m = require("./math.js")for the ESM file above and printed3 8with 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-levelawaitcannot be loaded this way. If you must support old versions, use dynamicimport(), 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:
// counter.jslet n = 0; export const inc = () => ++n;// cache.jsimport { inc } from "./counter.js";import { inc as inc2 } from "./counter.js";console.log(inc(), inc2(), inc());$ node cache.js1 2 3Both 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:
mkdir demo && cd demonpm init -y # creates package.jsonnpm install ms # downloads the package "ms" into node_modulesadded 1 package, and audited 2 packages in 433msfound 0 vulnerabilitiesNow package.json has a new section, and a package-lock.json file appeared:
{ "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" }}import ms from "ms";console.log(ms("2 days"), ms(60000));$ node usems.js172800000 1mThings to understand:
dependencieslists packages your program needs to run.devDependencies(install withnpm 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.jsonrecords 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, usenpm ci, which installs exactly what the lock file says and fails if it does not matchpackage.json.node_modulesholds the downloaded code. Never commit it. Add it to.gitignore.- Scripts are shortcuts.
npm run hiruns the command in"hi".npm startis a shortcut fornpm run start. Before the command runs, npm prints it (the> demo@1.0.0 hilines):
$ npm run hi> demo@1.0.0 hi> node -e "console.log('hi from npm run')"hi from npm runnpm view ms versionasks the registry for the latest version (it printed2.1.3for 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:
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 fileFor --watch, I started a script, appended a comment to the file, and Node reacted:
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:
| Module | What it is for |
|---|---|
node:fs, node:fs/promises | Files and folders (lesson 2) |
node:path | Building and cleaning file paths (lesson 2) |
node:stream, node:zlib | Streams and compression (lesson 3) |
node:http | Servers and clients (lesson 4) |
node:events | The EventEmitter class, used by almost everything |
node:crypto | Hashing, random numbers, encryption |
node:os, node:process | Information about the machine and the running program |
node:test, node:assert | A test runner (lesson 8) |
node:worker_threads | Real 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:
- I/O work (waiting) is cheap in Node. Use async calls and let many things wait together.
- 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.
- Run
node -p "process.versions.node"andnode -p "process.versions.v8". Write down both. - Create
hello.mjsthat prints your name fromprocess.argv(for examplenode hello.mjs AdaprintsHello, Ada). If no name is given, print a usage line to stderr and exit with code 2. - Run
npm init -y, then add"type": "module"topackage.json. Makeutil.jsthat exports a functionshout(s)returnings.toUpperCase() + "!", andmain.jsthat imports and prints it. Run it. - In the same folder, rename
util.jstoutil.cjsand change it to CommonJS. Try toimportit frommain.js. What works, and what do you have to change? - Run this and explain the order of the three lines:
console.log("a"); setTimeout(() => console.log("b"), 0); console.log("c"); - Run
node -e 'console.log(typeof require)'and then put the same line in a.mjsfile. Why do they differ?
Answers
- Your two version strings will differ from mine. For v22.22.0 the V8 version was
12.4.254.21-node.33. - A solution:JavaScript
const name = process.argv[2];if (!name) { console.error("usage: node hello.mjs <name>"); process.exit(2); }console.log(`Hello, ${name}`); main.jshasimport { shout } from "./util.js"; console.log(shout("hi"));andutil.jshasexport const shout = (s) => s.toUpperCase() + "!";. It printsHI!.import { shout } from "./util.cjs"works ifutil.cjsusesexports.shout = ...; you must also change the file name in the import. Named import detection usually findsshout. If it does not, use the default import and read.shoutfrom it.- 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. - In
-ecode and in.cjsfiles,requireexists (CommonJS). In.mjsfiles it isundefinedbecause ESM has norequire.
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.exitCodeandprocess.exit()are how a script talks to the shell. Exit code0means success.- ESM (
import) and CommonJS (require) are decided by the file extension and the"type"field. ESM needs file extensions, supports top-levelawaitand hasimport.meta.dirname. - Use the
node:prefix for built-ins. Commitpackage-lock.json. Usenpm ciin CI. Packages run with your full power, so install with care. node --watchandnode --env-filereplace 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
- How a Computer Runs Your Program · CS Foundations
- Security in Depth for Full-Stack Developers · Full-Stack Questions
- Node.js Internals Interviewers Ask About · Full-Stack Questions
Part of the study guide
Related series