Skip to content

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'; // ESM
const fs = require('node:fs/promises'); // CommonJS

process is a global object describing the current process. No import needed.

args.js
console.log(process.argv);
Terminal window
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.

process.env.NODE_ENV; // string | undefined — always a string, never a number
process.env.PORT ?? '3000'; // supply defaults yourself
process.env.DEBUG = '1'; // mutable, affects child processes you spawn

NODE_ENV has no built-in meaning to Node itself — it is a convention that libraries (notably Express and React’s build) read.

process.cwd(); // where the process was STARTED, not where the file lives
process.chdir('/tmp'); // rarely a good idea

process.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).

process.exitCode = 1; // preferred: set the code, let the process finish naturally
process.exit(1); // immediate — pending writes to stdout may be TRUNCATED

Exit code 0 means success; anything else means failure, and shells and CI depend on it.

process.stdin, process.stdout, and process.stderr are streams (see below), not simple functions.

upper.js
// A Unix filter: read stdin, uppercase it, write stdout.
for await (const chunk of process.stdin) {
process.stdout.write(chunk.toString().toUpperCase());
}
Terminal window
echo "hello" | node upper.js
# HELLO

console.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 colour
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.

Three flavours of the same API. Choose the promises one.

import fs from 'node:fs/promises'; // promise-based — default choice
import fs from 'node:fs'; // callback-based — legacy, and the sync functions
three-styles.js
import { 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 loop
const data = readFileSync('a.txt', 'utf8');
// 3. Promises — non-blocking, composable
const data = await readFileP('a.txt', 'utf8');

Sync is acceptable at startup and in short scripts. Inside a server it stops every other request.

files.js
import { readFile, writeFile, appendFile, mkdir, readdir, stat, rm } from 'node:fs/promises';
await writeFile('out.txt', 'hello\n'); // create or truncate
await appendFile('out.txt', 'world\n');
const text = await readFile('out.txt', 'utf8'); // string
const bytes = await readFile('out.txt'); // Buffer — no encoding given
await mkdir('a/b/c', { recursive: true }); // like mkdir -p, no error if it exists
const 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 -rf

Checking 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).

readFile loads the whole file into memory. A 2 GB log file will exhaust the heap. Stream it instead:

count-lines.js
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.

String concatenation with / breaks on Windows and doubles up separators. node:path does not.

paths.js
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' — normalises
path.resolve('src', 'index.js'); // '/cwd/src/index.js' — absolute, from cwd
path.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 only
path.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'); // true
path.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.js
import os from 'node:os';
os.platform(); // 'linux' | 'darwin' | 'win32'
os.arch(); // 'x64' | 'arm64'
os.cpus().length; // logical core count — how many workers to spawn
os.totalmem(); // bytes
os.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(); // seconds

os.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.

Most asynchronous Node objects — servers, sockets, streams, child processes — emit named events. EventEmitter is the base class, and you can use it directly.

emitter.js
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'

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
gzip.js
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.

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.

backpressure.js
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);
// Async iteration — the modern default
for 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().

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.

buffer.js
const b = Buffer.from('héllo', 'utf8');
console.log(b); // <Buffer 68 c3 a9 6c 6c 6f>
console.log(b.length); // 6 — BYTES, not characters
console.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 bytes
Buffer.concat([b, Buffer.from('!')]);
b.subarray(0, 2); // a VIEW onto the same memory, not a copy

Encodings 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.

server.js
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'));
Terminal window
curl localhost:3000/health
# {"ok":true,"uptime":0.42}
curl -d 'hi there' localhost:3000/echo
# hi there

Points worth noticing:

  • req.url is only the path and query (/echo?a=1) — never the full URL. Build a URL with a base to parse it.
  • Header names in req.headers are 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
});

Since Node 18, the WHATWG fetch API is available globally with no import and no dependency.

fetch.js
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 JSON
await 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'));
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.
  • Prefix core imports with node:.
  • process.env values are always strings; validate and coerce once at startup.
  • Prefer node:fs/promises; sync calls block every other request.
  • Use path.join/path.resolve instead of string concatenation, and check for traversal on user input.
  • EventEmitter.emit is synchronous, and an unhandled 'error' event crashes the process.
  • Streams give constant memory; pipeline from node:stream/promises handles errors and cleanup that .pipe() does not.
  • Buffer measures bytes, not characters, and is a Uint8Array.
  • fetch is built in from Node 18 and never rejects on a non-2xx status.