Node.js Built-in Modules

Node.js ships with dozens of built-in modulesfs, 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 requiring fs is effectively instant no matter how deep your project’s node_modules tree 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/.json extensions, then as a directory with an index.js).
  • 3. Otherwise, search node_modules. Node looks in the current directory’s node_modules, then walks up the directory tree checking each parent’s node_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/promises and async/await over the callback API for new code; reserve callbacks for legacy codebases or lessons specifically about them.
  • Never call a *Sync function 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 before listen() is called).
  • Always attach an error listener to any stream, server, or socket before data can start flowing through it.
  • For large files, stream them with fs.createReadStream or stream/promisespipeline() instead of reading the whole file into memory with readFile.
  • Build paths with path.join or path.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:os to 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 function changeExtension(filePath, newExt) that takes a path like "report.txt" and an extension like ".pdf", and returns "report.pdf". Use path.parse and path.format rather than manual string slicing.
  • Exercise 3: Write a script that lists every file in the current directory using fs/promisesreaddir, then uses fs.stat on 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 install needed, 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 like node:test.
  • Core module names always resolve before Node ever searches node_modules, so they can never be accidentally shadowed.
  • Async core APIs like fs.readFile often 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.resolve keep path-building correct across operating systems; manual string concatenation does not.