Core APIs
Node’s standard library is large but the useful surface is small. This page covers the modules you will reach for in nearly every project, and the two patterns — EventEmitter and streams — that everything else is built on.
Import core modules with the node: prefix. It is unambiguous, cannot be shadowed by a package in node_modules, and works in both module systems.
import fs from 'node:fs/promises'; // ESMconst fs = require('node:fs/promises'); // CommonJSprocess — the running program
Section titled “process — the running program”process is a global object describing the current process. No import needed.
Arguments
Section titled “Arguments”console.log(process.argv);node args.js build --minify -o dist# [# '/usr/bin/node', <- argv[0]: the node binary# '/home/me/args.js', <- argv[1]: the script# 'build', '--minify', '-o', 'dist'# ]Your arguments start at index 2, so process.argv.slice(2) is the idiom. For real flag parsing use parseArgs from node:util — see building a CLI.
Environment
Section titled “Environment”process.env.NODE_ENV; // string | undefined — always a string, never a numberprocess.env.PORT ?? '3000'; // supply defaults yourselfprocess.env.DEBUG = '1'; // mutable, affects child processes you spawnNODE_ENV has no built-in meaning to Node itself — it is a convention that libraries (notably Express and React’s build) read.
Working directory and paths
Section titled “Working directory and paths”process.cwd(); // where the process was STARTED, not where the file livesprocess.chdir('/tmp'); // rarely a good ideaprocess.cwd() and import.meta.dirname answer different questions. Use cwd() for user-supplied relative paths (a CLI argument); use import.meta.dirname for files that ship with your code (a template, a bundled asset).
Exiting
Section titled “Exiting”process.exitCode = 1; // preferred: set the code, let the process finish naturallyprocess.exit(1); // immediate — pending writes to stdout may be TRUNCATEDExit code 0 means success; anything else means failure, and shells and CI depend on it.
Standard streams
Section titled “Standard streams”process.stdin, process.stdout, and process.stderr are streams (see below), not simple functions.
// A Unix filter: read stdin, uppercase it, write stdout.for await (const chunk of process.stdin) { process.stdout.write(chunk.toString().toUpperCase());}echo "hello" | node upper.js# HELLOconsole.log writes to stdout, console.error to stderr. Send data to stdout and messages to stderr, so users can pipe your output without logs polluting it.
process.stdout.isTTY; // true when attached to a terminal — use it to decide on colourProcess events
Section titled “Process events”process.on('exit', (code) => { console.error('exiting with', code); // sync code only — the loop is already done});
process.on('SIGINT', () => { // Ctrl-C console.error('shutting down'); server.close(() => process.exit(0));});
process.on('uncaughtException', (err) => { console.error('fatal', err); process.exit(1);});
process.on('unhandledRejection', (reason) => { console.error('unhandled rejection', reason); process.exit(1);});Other useful bits: process.pid, process.platform, process.version, process.uptime(), process.memoryUsage(), process.hrtime.bigint() for high-resolution timing.
fs — the filesystem
Section titled “fs — the filesystem”Three flavours of the same API. Choose the promises one.
import fs from 'node:fs/promises'; // promise-based — default choiceimport fs from 'node:fs'; // callback-based — legacy, and the sync functionsimport { readFile, readFileSync } from 'node:fs';import { readFile as readFileP } from 'node:fs/promises';
// 1. Callback (error-first)readFile('a.txt', 'utf8', (err, data) => { if (err) throw err; console.log(data);});
// 2. Sync — BLOCKS the event loopconst data = readFileSync('a.txt', 'utf8');
// 3. Promises — non-blocking, composableconst data = await readFileP('a.txt', 'utf8');Sync is acceptable at startup and in short scripts. Inside a server it stops every other request.
Reading and writing
Section titled “Reading and writing”import { readFile, writeFile, appendFile, mkdir, readdir, stat, rm } from 'node:fs/promises';
await writeFile('out.txt', 'hello\n'); // create or truncateawait appendFile('out.txt', 'world\n');const text = await readFile('out.txt', 'utf8'); // stringconst bytes = await readFile('out.txt'); // Buffer — no encoding given
await mkdir('a/b/c', { recursive: true }); // like mkdir -p, no error if it existsconst names = await readdir('.'); // ['out.txt', ...]const entries = await readdir('.', { withFileTypes: true });for (const e of entries) console.log(e.name, e.isDirectory());
await rm('a', { recursive: true, force: true }); // like rm -rfChecking existence: don’t. Between the check and the use, the answer can change (a TOCTOU race). Just try the operation and handle the error:
try { const text = await readFile('config.json', 'utf8');} catch (err) { if (err.code !== 'ENOENT') throw err; // ENOENT = no such file // fall back to defaults}Error codes you will see: ENOENT (missing), EACCES (permission denied), EEXIST (already exists), EISDIR / ENOTDIR, EMFILE (too many open files).
Big files: use streams
Section titled “Big files: use streams”readFile loads the whole file into memory. A 2 GB log file will exhaust the heap. Stream it instead:
import { createReadStream } from 'node:fs';import { createInterface } from 'node:readline';
const rl = createInterface({ input: createReadStream('huge.log'), crlfDelay: Infinity, // treat \r\n as one line break});
let n = 0;for await (const line of rl) n++;console.log(n, 'lines');Memory stays flat regardless of file size.
path — filesystem paths, portably
Section titled “path — filesystem paths, portably”String concatenation with / breaks on Windows and doubles up separators. node:path does not.
import path from 'node:path';
path.join('src', 'lib', 'index.js'); // 'src/lib/index.js' (backslashes on Windows)path.join('/a/b', '../c'); // '/a/c' — normalisespath.resolve('src', 'index.js'); // '/cwd/src/index.js' — absolute, from cwdpath.resolve('/etc', '/var', 'log'); // '/var/log' — a leading / restarts
path.basename('/a/b/file.tar.gz'); // 'file.tar.gz'path.basename('/a/b/file.js', '.js'); // 'file'path.extname('/a/b/file.tar.gz'); // '.gz' — last dot onlypath.dirname('/a/b/file.js'); // '/a/b'
path.parse('/a/b/file.js');// { root: '/', dir: '/a/b', base: 'file.js', ext: '.js', name: 'file' }
path.sep; // '/' or '\\'path.isAbsolute('/a'); // truepath.relative('/a/b', '/a/c/d'); // '../c/d'join vs resolve: join glues segments together and normalises; resolve produces an absolute path, treating each segment as a cd. Use join for building a path from parts you control, resolve when you need something absolute.
node:path/posix and node:path/win32 force a specific flavour — useful when handling URL-ish paths on Windows.
os — the machine
Section titled “os — the machine”import os from 'node:os';
os.platform(); // 'linux' | 'darwin' | 'win32'os.arch(); // 'x64' | 'arm64'os.cpus().length; // logical core count — how many workers to spawnos.totalmem(); // bytesos.freemem();os.homedir(); // '/home/me'os.tmpdir(); // '/tmp'os.hostname();os.userInfo(); // { username, uid, gid, shell, homedir }os.EOL; // '\n' or '\r\n'os.uptime(); // secondsos.cpus().length is the usual default for a worker pool or a build’s parallelism. In a container it reports the host’s cores, not the cgroup CPU limit — check os.availableParallelism() (Node 18.14+), which respects more constraints.
EventEmitter — the callback pattern
Section titled “EventEmitter — the callback pattern”Most asynchronous Node objects — servers, sockets, streams, child processes — emit named events. EventEmitter is the base class, and you can use it directly.
import { EventEmitter } from 'node:events';
class Job extends EventEmitter { async run() { this.emit('start'); for (let i = 1; i <= 3; i++) { await new Promise((r) => setTimeout(r, 100)); this.emit('progress', i / 3); } this.emit('done', { ok: true }); }}
const job = new Job();job.on('start', () => console.log('started'));job.on('progress', (p) => console.log(`${Math.round(p * 100)}%`));job.once('done', (result) => console.log('done', result)); // fires at most once
await job.run();API surface:
| Method | Purpose |
|---|---|
on(name, fn) / addListener |
Subscribe. |
once(name, fn) |
Subscribe for exactly one emission. |
off(name, fn) / removeListener |
Unsubscribe — needs the same function reference. |
emit(name, ...args) |
Call every listener synchronously, in registration order. Returns true if there were any. |
listenerCount(name) |
How many are subscribed. |
removeAllListeners(name?) |
Nuclear option. |
Two things that catch people out:
emit is synchronous. Listeners run inline, in order, before emit returns. A slow listener blocks the emitter.
The 'error' event is special. If an emitter emits 'error' and nobody is listening, Node throws and the process crashes. Always attach an error listener to emitters that can fail.
socket.on('error', (err) => console.error('socket failed', err));Also worth knowing: attaching more than 10 listeners for the same event logs a MaxListenersExceededWarning. It is a leak detector, not a hard limit; raise it with emitter.setMaxListeners(n) if the count is genuinely intended.
Promise-friendly helper:
import { once } from 'node:events';const [result] = await once(job, 'done'); // resolves on 'done', rejects on 'error'Streams
Section titled “Streams”A stream processes data in chunks instead of all at once. This is what lets Node handle a 10 GB file or a slow HTTP upload in constant memory.
Four types:
| Type | Example |
|---|---|
| Readable | fs.createReadStream(), an incoming HTTP request, process.stdin |
| Writable | fs.createWriteStream(), an HTTP response, process.stdout |
| Duplex | Both ends independently — a TCP socket |
| Transform | A duplex that transforms — zlib.createGzip(), a cipher |
Piping
Section titled “Piping”import { createReadStream, createWriteStream } from 'node:fs';import { createGzip } from 'node:zlib';import { pipeline } from 'node:stream/promises';
await pipeline( createReadStream('input.log'), createGzip(), createWriteStream('input.log.gz'),);console.log('compressed');pipeline from node:stream/promises is the correct way to chain streams. The older a.pipe(b).pipe(c) works but does not forward errors or clean up — a failure mid-chain leaks file descriptors. Use pipeline.
Backpressure
Section titled “Backpressure”The reason streams exist. If a readable produces data faster than the writable can consume it, something must slow down. That signal is backpressure.
write() returns false when the writable’s internal buffer is over its highWaterMark (64 KB by default). The producer should stop and wait for the 'drain' event.
function writeMany(stream, n) { let i = 0; (function step() { while (i < n) { const ok = stream.write(`line ${i++}\n`); if (!ok) { stream.once('drain', step); // pause until the buffer clears return; } } stream.end(); })();}pipe and pipeline handle all of this for you. The failure mode when you ignore it:
// ✗ Reads the whole file into memory, then buffers it all in the response.res.end(await readFile('4gb.mp4'));
// ✓ Constant memory, respects how fast the client can receive.await pipeline(createReadStream('4gb.mp4'), res);Consuming a readable
Section titled “Consuming a readable”// Async iteration — the modern defaultfor await (const chunk of readable) { /* chunk is a Buffer */ }
// Collect it all (only when you know it is small)import { text, json, buffer } from 'node:stream/consumers';const body = await text(req);Node also implements the Web Streams API (ReadableStream, WritableStream, TransformStream) since Node 18, and converts between the two families with Readable.fromWeb() / Readable.toWeb().
Buffer — raw bytes
Section titled “Buffer — raw bytes”JavaScript strings are UTF-16 text. Buffer is Node’s type for arbitrary binary data — a subclass of Uint8Array, so every typed-array method works on it.
const b = Buffer.from('héllo', 'utf8');console.log(b); // <Buffer 68 c3 a9 6c 6c 6f>console.log(b.length); // 6 — BYTES, not charactersconsole.log('héllo'.length); // 5 — characters
b.toString('utf8'); // 'héllo'b.toString('hex'); // '68c3a96c6c6f'b.toString('base64'); // 'aMOpbGxv'
Buffer.from('aMOpbGxv', 'base64').toString('utf8'); // 'héllo'
Buffer.alloc(8); // 8 zero-filled bytesBuffer.concat([b, Buffer.from('!')]);b.subarray(0, 2); // a VIEW onto the same memory, not a copyEncodings supported: utf8, hex, base64, base64url, ascii, latin1, utf16le.
Slicing a multi-byte character in half produces mojibake. When decoding a stream chunk by chunk, use StringDecoder from node:string_decoder, which holds partial characters across chunk boundaries — or just set an encoding on the stream.
A minimal HTTP server
Section titled “A minimal HTTP server”import { createServer } from 'node:http';
const server = createServer(async (req, res) => { // req is a Readable stream, res is a Writable stream const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/health') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ok: true, uptime: process.uptime() })); }
if (req.method === 'POST' && url.pathname === '/echo') { const chunks = []; for await (const chunk of req) chunks.push(chunk); const body = Buffer.concat(chunks).toString('utf8'); res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(body); }
res.writeHead(404).end('Not Found');});
server.listen(3000, () => console.log('listening on http://localhost:3000'));curl localhost:3000/health# {"ok":true,"uptime":0.42}curl -d 'hi there' localhost:3000/echo# hi therePoints worth noticing:
req.urlis only the path and query (/echo?a=1) — never the full URL. Build aURLwith a base to parse it.- Header names in
req.headersare lowercased by Node. - You must call
res.end(), or the client hangs until it times out. - There is no routing, no body parsing, no cookies. Express, Fastify, and Hono exist to supply those; this module is the layer underneath them.
Graceful shutdown:
process.on('SIGTERM', () => { server.close(() => process.exit(0)); // stop accepting, finish in-flight requests});fetch — HTTP client (Node 18+)
Section titled “fetch — HTTP client (Node 18+)”Since Node 18, the WHATWG fetch API is available globally with no import and no dependency.
const res = await fetch('https://api.github.com/repos/nodejs/node', { headers: { accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(5000), // Node 17.3+ — abort after 5s});
if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data.stargazers_count);// POST JSONawait fetch('https://example.com/api', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'x' }),});Differences from the browser: no CORS (it is not a browser), no cookie jar, and no automatic proxy support — set undici’s ProxyAgent if you need one. The response body is a Web ReadableStream, so streaming a download to disk needs a conversion:
import { Readable } from 'node:stream';import { createWriteStream } from 'node:fs';import { pipeline } from 'node:stream/promises';
const res = await fetch('https://example.com/big.zip');await pipeline(Readable.fromWeb(res.body), createWriteStream('big.zip'));Other modules worth knowing exist
Section titled “Other modules worth knowing exist”| Module | Use |
|---|---|
node:crypto |
Hashing, HMAC, random values (randomUUID(), randomBytes()), ciphers. |
node:child_process |
Run other programs — spawn (streaming), execFile (buffered). Avoid exec with untrusted input; it goes through a shell. |
node:worker_threads |
Real parallelism for CPU-bound work. |
node:util |
promisify, parseArgs, inspect, styleText. |
node:url |
fileURLToPath, pathToFileURL. |
node:readline/promises |
Interactive prompts. |
node:assert |
Assertions, used heavily by the built-in test runner. |
node:timers/promises |
await setTimeout(1000) without wrapping it yourself. |
Key points
Section titled “Key points”- Prefix core imports with
node:. process.envvalues are always strings; validate and coerce once at startup.- Prefer
node:fs/promises; sync calls block every other request. - Use
path.join/path.resolveinstead of string concatenation, and check for traversal on user input. EventEmitter.emitis synchronous, and an unhandled'error'event crashes the process.- Streams give constant memory;
pipelinefromnode:stream/promiseshandles errors and cleanup that.pipe()does not. Buffermeasures bytes, not characters, and is aUint8Array.fetchis built in from Node 18 and never rejects on a non-2xx status.