Node.js Built-in Modules
Node.js ships with dozens of built-in modules — fs, path, http, os, events, crypto, and more — compiled directly into the runtime itself. You never run npm install to get them; they are simply there the moment Node starts. They give your JavaScript code the things a browser never could: filesystem access, TCP/HTTP networking, process control, cryptography, and OS-level information. Understanding what the standard library offers, and exactly how Node finds these modules before it ever looks in node_modules, is the foundation everything else in this course builds on.
Overview: How Built-in Modules Work
A "built-in" (or "core") module is a chunk of JavaScript (and, for some modules, C++) that is compiled directly into the node binary when Node itself is built. That means core modules are versioned together with Node — when you upgrade Node, you upgrade fs, http, and the rest along with it. There is no package.json entry for them and no folder to inspect in node_modules.
Since Node 16, every core module can be loaded with an explicit node: prefix, e.g. require("node:fs") instead of require("fs"). Both forms work for long-standing modules like fs, path, and http, but the prefix is the currently recommended style: it makes it instantly obvious to anyone reading the code that the module is built into Node rather than an npm dependency, and it removes any ambiguity for tooling. A handful of newer additions — node:test, node:sea, and node:sqlite among them — are only available with the prefix; there is no bare require("test") that resolves to Node’s test runner.
Under the hood, many core modules are thin JavaScript wrappers around libuv, the C library that gives Node its cross-platform asynchronous I/O. When you call an async method like fs.readFile, Node cannot make the underlying filesystem read non-blocking at the operating-system level on every platform, so it hands that work off to libuv’s thread pool (four threads by default) to run in the background. Your JavaScript keeps executing immediately; once the OS call finishes on a worker thread, libuv queues your callback to run on the main thread during the event loop’s poll phase. This is why a slow disk read never freezes the rest of your program — the blocking work happens off the single JavaScript thread, not on it. Modules like http and net, by contrast, use the operating system’s native non-blocking socket APIs directly and don’t need the thread pool at all; crypto‘s heavier operations (like scrypt or pbkdf2) do use it, because they’re CPU-bound.
Module resolution order matters too. When you call require("fs") or import "node:path", Node checks its internal registry of core module identifiers first, before it ever touches the disk to search node_modules. That’s why you can never accidentally shadow fs or http by installing an npm package with the same name — core modules always win. Using the node: prefix makes this guarantee explicit and unambiguous, which is exactly why newer modules require it.
Syntax
The general form for loading a core module is the same require/import syntax you use for any module — only the specifier string changes:
const moduleName = require("node:module-name");
// equivalent bare form for long-standing modules:
// const moduleName = require("module-name");
| Part | Meaning |
|---|---|
require(...) |
CommonJS function that loads a module and returns its exports |
"node:module-name" |
The specifier — the node: prefix marks it as a core module |
moduleName |
Local binding you use to call the module’s functions |
Some of the most commonly used core modules:
| Module | Purpose |
|---|---|
node:fs / node:fs/promises |
Read, write, and watch files and directories |
node:path |
Build and parse filesystem paths in a cross-platform way |
node:http / node:https |
Create HTTP(S) servers and make requests |
node:os |
Query OS-level info: platform, CPUs, memory, network interfaces |
node:events |
The EventEmitter class that underlies streams, servers, and more |
node:util |
Utilities like promisify, inspect, and type checks |
node:crypto |
Hashing, HMACs, random bytes, and encryption |
node:stream |
Readable, Writable, Duplex, and Transform stream base classes |
node:child_process |
Spawn and manage other OS processes |
node:test |
Node’s built-in test runner (stable since Node 20) |
Examples
Example 1: Inspecting the system with os and process
const os = require("node:os");
console.log("Platform:", os.platform());
console.log("CPU cores:", os.cpus().length);
console.log("Free memory (MB):", Math.round(os.freemem() / 1024 / 1024));
console.log("Node.js version:", process.version);
console.log("Current working directory:", process.cwd());
Output:
Platform: linux
CPU cores: 8
Free memory (MB): 2731
Node.js version: v22.5.0
Current working directory: /home/user/projects/app
Note that os isn’t technically a required prerequisite for these Node.js APIs — process is a global available everywhere without any require at all, since it describes the currently running Node process itself. The os module, by contrast, must be imported explicitly and reports on the machine Node is running on: exact values will differ on every computer that runs this script.
Example 2: Cross-platform paths with path
const path = require("node:path");
const filePath = "/home/user/projects/app/config.json";
console.log("Directory:", path.dirname(filePath));
console.log("File name:", path.basename(filePath));
console.log("Extension:", path.extname(filePath));
console.log("Joined path:", path.join("data", "logs", "2026-07-31.log"));
console.log("Is absolute:", path.isAbsolute(filePath));
Output:
Directory: /home/user/projects/app
File name: config.json
Extension: .json
Joined path: data/logs/2026-07-31.log
Is absolute: true
path.join is the detail that matters most here: it inserts the correct separator for the current operating system (/ on Linux and macOS, \ on Windows) and cleans up redundant slashes, so code that builds paths with plain string concatenation (dir + "/" + file) will silently break on Windows while path.join never does.
Example 3: A realistic config loader
const fs = require("node:fs/promises");
const path = require("node:path");
async function loadConfig(configPath) {
try {
const raw = await fs.readFile(configPath, "utf8");
return JSON.parse(raw);
} catch (err) {
if (err.code === "ENOENT") {
console.warn(`No config found at ${configPath}, using defaults`);
return { port: 3000, logLevel: "info" };
}
throw err;
}
}
async function main() {
const configPath = path.join(__dirname, "config.json");
const config = await loadConfig(configPath);
console.log("Loaded config:", config);
const files = await fs.readdir(__dirname);
console.log("Files in this directory:", files);
}
main().catch((err) => {
console.error("Fatal error:", err.message);
process.exit(1);
});
Output (when config.json is missing from the directory):
No config found at /home/user/projects/app/config.json, using defaults
Loaded config: { port: 3000, logLevel: 'info' }
Files in this directory: [ 'app.js', 'package.json' ]
This combines three core modules that almost every real Node program touches: fs/promises for non-blocking file I/O, path to build a reliable absolute path, and the implicit process global to exit with a non-zero status on failure. Notice the error handling: a missing file (ENOENT) is treated as an expected case with a fallback, while any other error — a permissions problem, invalid JSON — is re-thrown and ultimately caught by main().catch(...), which logs it and exits instead of letting the program continue in a broken state.
How Module Resolution Works Step by Step
When your code calls require(specifier) (or an import is resolved), Node walks through a fixed algorithm:
- 1. Is it a core module name? If the specifier matches a built-in (with or without the
node:prefix, for modules that allow both), Node resolves it immediately from memory — no disk access at all. This is why requiringfsis effectively instant no matter how deep your project’snode_modulestree is. - 2. Does it start with
./,../, or/? If so, it’s treated as a relative or absolute file path and resolved directly on disk (trying the exact name, then with.js/.jsonextensions, then as a directory with anindex.js). - 3. Otherwise, search
node_modules. Node looks in the current directory’snode_modules, then walks up the directory tree checking each parent’snode_modules, until it finds a match or reaches the filesystem root.
Once a module — core or otherwise — has been loaded, Node caches its exports object keyed by resolved identity. Every later require for that same module, anywhere in your program, returns the exact same object rather than re-running the module’s code. This matters in practice: if you mutate something reachable from a core module’s exports (rare, but possible with objects like process), every other file that requires it sees the change too.
Common Mistakes
Mistake 1: Blocking the event loop with a sync method inside a request handler
Sync methods (anything ending in Sync, like fs.readFileSync) run on the main thread and block everything else — every other request, every timer — until they finish. That’s acceptable during one-time startup work, but fatal inside a server’s request handler.
const http = require("node:http");
const fs = require("node:fs");
http.createServer((req, res) => {
const data = fs.readFileSync("./large-file.txt", "utf8"); // blocks every other request
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(data);
}).listen(3000);
Every concurrent request now waits in line behind whichever one is currently reading the file. Stream the response instead, so the event loop stays free to handle other connections while the disk read happens:
const http = require("node:http");
const fs = require("node:fs");
http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
const stream = fs.createReadStream("./large-file.txt", "utf8");
stream.on("error", (err) => {
console.error("Read failed:", err.message);
res.writeHead(500);
res.end("Server error");
});
stream.pipe(res);
}).listen(3000);
Mistake 2: No error listener on a stream or emitter
Streams, servers, and sockets are all EventEmitters, and by design, an EventEmitter that emits "error" with nobody listening throws that error and crashes the process. This is deliberate — Node refuses to let errors disappear silently — but it surprises people used to other event patterns.
const fs = require("node:fs");
const stream = fs.createReadStream("./maybe-missing.txt");
stream.on("data", (chunk) => {
console.log(chunk.toString());
});
// if the file doesn't exist, this crashes the whole process
Always attach an error handler to anything derived from EventEmitter:
const fs = require("node:fs");
const stream = fs.createReadStream("./maybe-missing.txt");
stream.on("error", (err) => {
console.error("Could not read file:", err.message);
});
stream.on("data", (chunk) => {
console.log(chunk.toString());
});
Mistake 3: Assuming every core module works without the node: prefix
Older modules like fs and path accept the bare name for backward compatibility, but several newer modules do not — they were introduced only with the prefixed form to avoid ever colliding with a same-named package on npm.
const test = require("test"); // throws: Cannot find module 'test'
Use the prefix, which always works and is the current best practice regardless of the module’s age:
const test = require("node:test");
Best Practices
- Always use the
node:prefix for core modules — it’s unambiguous, required for newer modules, and costs nothing for older ones. - Prefer
node:fs/promisesandasync/awaitover the callback API for new code; reserve callbacks for legacy codebases or lessons specifically about them. - Never call a
*Syncfunction inside code that handles concurrent requests or runs on every event loop tick; only use sync methods for one-time startup logic (e.g. reading a config file beforelisten()is called). - Always attach an
errorlistener to any stream, server, or socket before data can start flowing through it. - For large files, stream them with
fs.createReadStreamorstream/promises‘pipeline()instead of reading the whole file into memory withreadFile. - Build paths with
path.joinorpath.resolve, never manual string concatenation, so your code works identically on Linux, macOS, and Windows. - Check the official Node.js API docs for the version you target — some modules (like
node:sqlite) are still marked experimental and can change between releases.
Practice Exercises
- Exercise 1: Write a script that uses
node:osto print the total and free memory as a percentage, and the number of CPU cores. Format the percentage to two decimal places. - Exercise 2: Using
node:path, write a functionchangeExtension(filePath, newExt)that takes a path like"report.txt"and an extension like".pdf", and returns"report.pdf". Usepath.parseandpath.formatrather than manual string slicing. - Exercise 3: Write a script that lists every file in the current directory using
fs/promises‘readdir, then usesfs.staton each entry to print whether it’s a file or a directory. Make sure a missing or unreadable entry doesn’t crash the whole script.
Summary
- Built-in modules are compiled into the Node binary — no
npm installneeded, and they’re versioned with Node itself. - The
node:prefix (e.g.node:fs) is the recommended style and is mandatory for some newer modules likenode:test. - Core module names always resolve before Node ever searches
node_modules, so they can never be accidentally shadowed. - Async core APIs like
fs.readFileoften hand blocking OS work to libuv’s thread pool, keeping the main JavaScript thread free. - Sync methods (
*Sync) block the event loop and should never run inside a server’s request-handling path. EventEmitter-based APIs (streams, servers, sockets) crash the process if an"error"event has no listener — always attach one.path.join/path.resolvekeep path-building correct across operating systems; manual string concatenation does not.
