cheat sheet
Node.js Runtime
How to use the Node.js runtime — REPL, running scripts, CLI flags, ESM vs CJS modules, built-in node: modules, the process object, and modern globals like fetch and structuredClone.
Node.js Runtime
What it is
Node.js is a JavaScript runtime built on Chrome's V8 engine that lets you execute JavaScript outside the browser. It uses a single-threaded, non-blocking event loop to handle I/O concurrently — making it efficient for servers, CLI tools, and scripting without threads. Node ships with a standard library of built-in modules (file system, networking, crypto, etc.) accessible via the node: protocol prefix.
Node ships under an LTS-and-current cadence: even-numbered major releases (18, 20, 22, 24) become LTS in October of their release year, and odd majors are short-lived "current" releases that get superseded six months later. For production, stick to whichever even-numbered major is in Active LTS. Sibling runtimes that aim for Node compatibility — Bun (Zig + JavaScriptCore) and Deno (Rust + V8) — reimplement parts of Node's surface but are not drop-in equivalents; see the matrix at the bottom of this page.
REPL
Start the interactive Read–Eval–Print Loop by running node with no arguments:
node
Output:
Welcome to Node.js v22.14.0.
Type ".help" for more information.
>
Useful REPL commands:
> .help # show all dot-commands
> .exit # exit the REPL (or Ctrl+D)
> .break # abort current multi-line expression
> .editor # enter editor mode (paste multi-line code, Ctrl+D to run)
> .load file.js # execute a file inside the REPL session
> .save file.js # save the current session history to a file
The _ variable holds the last evaluated result:
> 2 + 2
4
> _ * 10
40
> 'hello'.toUpperCase()
'HELLO'
> _
'HELLO'
Multi-line in the REPL (Node detects incomplete expressions automatically):
> function greet(name) {
... return `Hello, ${name}!`;
... }
undefined
> greet('world')
'Hello, world!'
Running a script
node script.js
Output: (none — exits 0 on success)
Passing arguments
Arguments after the script name are available in process.argv:
node script.js foo bar
Output: (none — exits 0 on success)
// script.js
console.log(process.argv);
// process.argv[0] = path to node binary
// process.argv[1] = path to script
// process.argv[2+] = user arguments
const args = process.argv.slice(2);
console.log('User args:', args);
Output:
[
'/usr/local/bin/node',
'/home/user/script.js',
'foo',
'bar'
]
User args: [ 'foo', 'bar' ]
--watch flag (v18+)
Restart the script automatically when source files change — no nodemon required for development:
node --watch server.js
Output:
(node:12345) ExperimentalWarning: Watch mode is an experimental feature and might change at any time
Restarting 'server.js'
Server listening on port 3000
Watch a specific file pattern:
node --watch --watch-path=src server.js
Output: (none — exits 0 on success)
--env-file flag (v20.6+)
Load environment variables from a file without a third-party library:
node --env-file=.env server.js
Output: (none — exits 0 on success)
# .env
PORT=3000
DATABASE_URL=postgres://localhost/mydb
// server.js — process.env is populated from .env automatically
console.log(process.env.PORT); // '3000'
console.log(process.env.DATABASE_URL);
Multiple env files (right-most wins on conflict):
node --env-file=.env --env-file=.env.local server.js
Output: (none — exits 0 on success)
--import flag (ES modules preload)
Run a module before the entry point — useful for registering TypeScript loaders, instrumentation, or polyfills:
node --import ./register.js server.js
Output: (none — exits 0 on success)
--experimental-strip-types (v22.6+)
Node can now strip TypeScript syntax at runtime without a separate transpile step. Type annotations are erased; nothing is type-checked. Pair with --experimental-transform-types for enum/namespace support.
node --experimental-strip-types server.ts
Output: (none — exits 0 on success)
# Type-stripping + ESM transform
node --experimental-strip-types --experimental-transform-types index.ts
Output: (none — exits 0 on success)
In Node 24+, the flag becomes the default for .ts files. Use tsc --noEmit in CI to keep type-checking; the runtime alone won't catch type errors.
Useful inspection flags
node --inspect server.js # Chrome DevTools debugger on port 9229
node --inspect-brk server.js # Break before the first line of user code
node --trace-warnings server.js # Print stack traces for runtime warnings
node --trace-uncaught server.js # Stack trace when an uncaught error fires
node --report-uncaught-exception app.js # Write a diagnostic report on crash
node --max-old-space-size=4096 app.js # Raise V8 heap limit to 4 GB
Output: (none — exits 0 on success)
The event loop
The event loop is the scheduler that makes Node single-threaded but non-blocking. Each tick of the loop processes one queue of callbacks, then moves on to the next. Understanding the phases explains why setImmediate sometimes runs before setTimeout, and why process.nextTick can starve I/O if you abuse it.
┌───────────────────────────────────────┐
┌─>│ timers (setTimeout) │
│ └────────────┬──────────────────────────┘
│ ┌────────────┴──────────────────────────┐
│ │ pending callbacks (deferred I/O) │
│ └────────────┬──────────────────────────┘
│ ┌────────────┴──────────────────────────┐
│ │ idle, prepare (internal) │
│ └────────────┬──────────────────────────┘
│ ┌────────────┴──────────────────────────┐
│ │ poll (incoming I/O, fs/net callbacks│
│ └────────────┬──────────────────────────┘
│ ┌────────────┴──────────────────────────┐
│ │ check (setImmediate) │
│ └────────────┬──────────────────────────┘
│ ┌────────────┴──────────────────────────┐
└──┤ close callbacks (socket.on('close'))│
└───────────────────────────────────────┘
Between phases (and after each callback) Node drains two extra queues:
process.nextTickqueue — runs as soon as the current operation completes.- microtasks — Promise reactions (
.then,await),queueMicrotask.
process.nextTick is processed before microtasks, both happen before the loop moves to the next phase.
Microtask vs macrotask ordering
console.log("1: sync");
setTimeout(() => console.log("4: setTimeout"), 0);
setImmediate(() => console.log("5: setImmediate"));
Promise.resolve().then(() => console.log("3: microtask"));
process.nextTick(() => console.log("2: nextTick"));
console.log("0: top of script");
Output:
1: sync
0: top of script
2: nextTick
3: microtask
4: setTimeout
5: setImmediate
The ordering of setTimeout(fn, 0) vs setImmediate(fn) is not guaranteed at the top level — both will run, but the order depends on the loop's current phase. Inside an I/O callback, setImmediate always wins (the next phase is check):
import { readFile } from "node:fs";
readFile(import.meta.url, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
Output:
immediate
timeout
process.nextTick — use sparingly
process.nextTick runs before any I/O. It's useful for deferring an action to the end of the current synchronous frame (e.g. emitting an event after the constructor returns), but recursive nextTick calls starve the event loop:
// BAD — never lets the loop progress
function spin() {
process.nextTick(spin);
}
spin();
// HTTP servers stop responding; setInterval/timers never fire.
Output: (none — process becomes unresponsive)
Use queueMicrotask if you only need "next microtask"; use setImmediate if you want "next loop tick."
setImmediate vs setTimeout(0) vs queueMicrotask
| API | Runs in phase | Starves I/O? |
|---|---|---|
process.nextTick(fn) | Between phases (before microtasks) | Yes if recursive |
queueMicrotask(fn) | Microtask queue | Yes if recursive |
setImmediate(fn) | check phase | No (yields to poll) |
setTimeout(fn, 0) | timers phase (min 1 ms in practice) | No |
ESM vs CommonJS (CJS)
Node supports two module systems. Understanding which one is active in a given file is essential.
Enable ESM
Three ways to opt in to ES modules:
// package.json — makes ALL .js files in the package ESM
{
"type": "module"
}
// Use the .mjs extension — always ESM regardless of package.json
// math.mjs
export function add(a, b) { return a + b; }
// Use the .cjs extension — always CommonJS regardless of package.json
// util.cjs
module.exports = { greet: (name) => `Hello, ${name}` };
Import vs require
// ESM — top-level import (static)
import { readFile } from 'node:fs/promises';
import express from 'express';
import data from './data.json' with { type: 'json' };
// CJS — require (synchronous, dynamic)
const fs = require('node:fs');
const express = require('express');
Dynamic import (works in both ESM and CJS)
// Load a module at runtime — always returns a Promise
const { default: chalk } = await import('chalk');
// Useful in CJS files that need to load an ESM-only module
async function loadModule() {
const { something } = await import('./esm-only.mjs');
return something;
}
Key differences table
ESM (import/export) | CJS (require) | |
|---|---|---|
| File extension default | .js (with "type":"module") / .mjs | .js (default) / .cjs |
__dirname / __filename | Not available (use import.meta.url) | Available |
Top-level await | Yes | No |
| Named exports | Yes | Simulated via object properties |
| Tree-shakeable by bundlers | Yes | No |
| Interop: ESM importing CJS | Yes (default import only) | No (can't require() ESM) |
import.meta in ESM (replaces __dirname)
// ESM equivalent of __dirname and __filename
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dataPath = join(__dirname, 'data', 'file.json');
Built-in node: modules
Use the node: prefix for clarity and to avoid shadowing by npm packages:
node:fs — file system
Provides synchronous, callback, and Promise-based APIs for reading, writing, and managing files and directories. Prefer the node:fs/promises sub-module (async/await) in servers to avoid blocking the event loop; the sync variants are fine in CLI scripts and build tools.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
// Sync (blocks event loop — avoid in servers)
const content = readFileSync('file.txt', 'utf8');
// Async (preferred)
const data = await readFile('file.txt', 'utf8');
await writeFile('out.txt', 'hello world', 'utf8');
await mkdir('new-dir', { recursive: true });
const entries = await readdir('./src');
console.log(entries);
Output (readdir):
[ 'index.js', 'utils.js', 'config.json' ]
node:path — path utilities
Handles file path construction and parsing in a platform-agnostic way — path.join uses the OS separator (backslash on Windows, forward slash on POSIX), making code portable without manual string concatenation.
import path from 'node:path';
path.join('/home/user', 'docs', 'file.txt'); // '/home/user/docs/file.txt'
path.resolve('src', 'index.js'); // absolute path
path.basename('/home/user/docs/file.txt'); // 'file.txt'
path.extname('script.min.js'); // '.js'
path.dirname('/home/user/docs/file.txt'); // '/home/user/docs'
path.parse('/home/user/file.txt');
// { root: '/', dir: '/home/user', base: 'file.txt', ext: '.txt', name: 'file' }
node:url — URL parsing
Exposes the WHATWG URL class for standards-compliant URL parsing and manipulation, plus fileURLToPath / pathToFileURL helpers — the standard way to get a file path from import.meta.url in ESM when import.meta.dirname is unavailable.
import { URL, fileURLToPath, pathToFileURL } from 'node:url';
const url = new URL('https://example.com/path?q=1#anchor');
url.hostname; // 'example.com'
url.pathname; // '/path'
url.searchParams.get('q'); // '1'
// Convert file URL ↔ path (useful in ESM)
fileURLToPath(import.meta.url); // '/absolute/path/to/current/file.js'
pathToFileURL('/home/user/file.js').href; // 'file:///home/user/file.js'
node:crypto — cryptography
Provides hashing (SHA-256, MD5), HMAC, cipher/decipher, and secure random generation. For modern code, prefer the subtle property (crypto.subtle) which exposes the Web Crypto API — the same interface available in browsers and Deno.
import { createHash, randomBytes, randomUUID } from 'node:crypto';
// SHA-256 hash
const hash = createHash('sha256').update('hello').digest('hex');
console.log(hash);
// '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
// Secure random bytes
const token = randomBytes(32).toString('hex'); // 64-char hex string
// UUID v4
const id = randomUUID();
console.log(id); // 'b1f7e3a2-4c9d-4e8f-a12b-3d5e7c9f1a2b'
node:os — operating system info
Returns read-only system information — platform, CPU cores, total/free memory, home directory — without spawning external processes. Useful for adapting runtime behaviour (e.g. default concurrency based on os.cpus().length).
import os from 'node:os';
os.platform(); // 'linux' | 'darwin' | 'win32'
os.arch(); // 'x64' | 'arm64'
os.cpus().length; // number of CPU cores
os.totalmem(); // total RAM in bytes
os.freemem(); // free RAM in bytes
os.homedir(); // '/home/user'
os.tmpdir(); // '/tmp'
os.hostname(); // 'my-machine'
os.networkInterfaces(); // network interface details
node:child_process — spawn subprocesses
Lets Node.js launch external programs. exec / execSync capture stdout/stderr as a string buffer (convenient but unsuitable for large output); spawn streams I/O and is preferred for long-running processes; fork is a specialised spawn for Node child processes with an IPC channel.
import { execSync, exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
// Sync — blocks until done (fine for CLI scripts)
const output = execSync('git status', { encoding: 'utf8' });
// Async with callback
exec('ls -la', (err, stdout, stderr) => {
if (err) throw err;
console.log(stdout);
});
// Promisified
const execAsync = promisify(exec);
const { stdout } = await execAsync('node --version');
console.log(stdout.trim()); // 'v22.14.0'
// Streaming (best for long-running or large output)
const proc = spawn('npm', ['install'], { stdio: 'inherit' });
proc.on('close', (code) => console.log(`Exited with code ${code}`));
node:util — utilities
A grab-bag of helpers for working with Node internals: promisify converts callback APIs to Promises, inspect produces detailed debug representations of any value, format handles printf-style string formatting, and util.types provides reliable type predicates.
import util from 'node:util';
// Promisify callback-based APIs
const sleep = util.promisify(setTimeout);
await sleep(1000);
// Deep inspect objects (better than JSON.stringify for debugging)
console.log(util.inspect({ a: 1, b: [2, 3] }, { depth: null, colors: true }));
// Format strings (printf-style)
util.format('Hello %s, you are %d years old', 'Alice', 30);
// 'Hello Alice, you are 30 years old'
// Type checks
util.types.isAsyncFunction(async () => {}); // true
util.types.isPromise(Promise.resolve()); // true
The process object
process is a global — no import needed:
// Environment variables
process.env.NODE_ENV // 'development' | 'production' | 'test'
process.env.PORT // string or undefined
// Command-line arguments
process.argv // ['node', 'script.js', ...userArgs]
process.argv.slice(2) // just the user-supplied args
// Working directory
process.cwd() // '/home/user/myproject'
// Platform
process.platform // 'linux' | 'darwin' | 'win32'
process.arch // 'x64' | 'arm64'
// Node version
process.version // 'v22.14.0'
process.versions.v8 // '12.4.254.21-node.22'
// Exit
process.exit(0) // success
process.exit(1) // failure (non-zero = error by convention)
// stdin / stdout / stderr
process.stdout.write('no newline');
process.stderr.write('error output\n');
// Event hooks
process.on('exit', (code) => console.log(`Exiting with code ${code}`));
process.on('uncaughtException', (err) => {
console.error('Uncaught:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});
// Memory usage
console.log(process.memoryUsage());
Output (process.memoryUsage()):
{
rss: 38502400,
heapTotal: 6291456,
heapUsed: 4873616,
external: 422056,
arrayBuffers: 17382
}
Modern globals (no import needed)
fetch (v18+)
// Built-in — no node-fetch package needed
const res = await fetch('https://api.github.com/repos/nodejs/node');
const data = await res.json();
console.log(data.stargazers_count);
Output:
107234
structuredClone (v17+)
Deep-clone any serializable value — replaces JSON.parse(JSON.stringify(obj)):
const original = { a: 1, nested: { b: [1, 2, 3] } };
const clone = structuredClone(original);
clone.nested.b.push(4);
console.log(original.nested.b); // [1, 2, 3] — unaffected
console.log(clone.nested.b); // [1, 2, 3, 4]
crypto.randomUUID() (v19+)
// Global — no import required
const id = crypto.randomUUID();
console.log(id); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
globalThis
A standardised reference to the global object that works identically in Node.js, browsers, and Web Workers — replacing the need to pick between global, window, or self depending on the runtime.
globalThis.myGlobal = 'shared';
console.log(globalThis.myGlobal); // 'shared'
setTimeout / setInterval / clearTimeout / clearImmediate
// All return a Timeout object; can be awaited with timers/promises
import { setTimeout as sleep } from 'node:timers/promises';
await sleep(1000); // pause for 1 second
await sleep(500, 'result'); // resolves to 'result' after 500ms
import { setInterval } from 'node:timers/promises';
for await (const _ of setInterval(1000)) {
console.log('tick'); // logs every second
}
node:worker_threads — true parallelism
JavaScript runs single-threaded, but CPU-bound work (image resizing, parsing, crypto) can be offloaded to a worker thread without blocking the main event loop. Workers run a separate V8 isolate and have their own event loop; communication happens via structured-clone-serialized messages.
Use workers for CPU-bound work. For I/O concurrency, the event loop is enough — workers add overhead and complicate debugging.
// main.js
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./hash-worker.js", import.meta.url));
worker.postMessage({ input: "hello world" });
worker.on("message", (result) => {
console.log("Got:", result);
worker.terminate();
});
// hash-worker.js
import { parentPort } from "node:worker_threads";
import { createHash } from "node:crypto";
parentPort.on("message", ({ input }) => {
const digest = createHash("sha256").update(input).digest("hex");
parentPort.postMessage(digest);
});
Output:
Got: b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
Sharing memory with SharedArrayBuffer
For workloads where copying the input is too expensive, pass a SharedArrayBuffer and use Atomics for safe access:
import { Worker } from "node:worker_threads";
const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
const worker = new Worker(
`import { parentPort, workerData } from 'node:worker_threads';
const view = new Int32Array(workerData);
Atomics.add(view, 0, 42);
parentPort.postMessage('done');`,
{ eval: true, workerData: shared }
);
worker.on("message", () => {
console.log(view[0]); // 42
worker.terminate();
});
Output:
42
Worker pool
For repeated tasks, reuse workers instead of spinning a new one per request. Libraries like piscina (by Matteo Collina) provide a battle-tested pool with backpressure, abort, and per-task timeouts.
npm install piscina
Output:
added 1 package in 1s
import Piscina from "piscina";
const pool = new Piscina({
filename: new URL("./worker.js", import.meta.url).href,
maxThreads: 4,
});
const result = await pool.run({ input: "hello" });
console.log(result);
Output:
{ hash: '...' }
node:child_process — spawning subprocesses
child_process lets Node launch external programs. Choose the right function based on the workload: spawn for long-running or streaming processes, exec/execSync for short commands that return a buffer of stdout, and fork to spawn a Node child with a built-in IPC channel.
| Function | Returns | When to use |
|---|---|---|
spawn(cmd, args) | ChildProcess (streams) | Long-running, streaming, large output |
exec(cmd, cb) | ChildProcess (buffered) | Short commands, small output (max ~1 MB) |
execFile(cmd, args, cb) | ChildProcess (buffered) | Like exec but no shell — safer (no shell injection) |
execSync(cmd) | Buffer | Build scripts, one-off CLI |
fork(modulePath) | ChildProcess + IPC | Spawn another Node script with process.send |
spawn — streaming
import { spawn } from "node:child_process";
const proc = spawn("rg", ["TODO", "src/"], { stdio: ["ignore", "pipe", "pipe"] });
proc.stdout.on("data", (chunk) => process.stdout.write(chunk));
proc.stderr.on("data", (chunk) => process.stderr.write(chunk));
proc.on("close", (code) => console.log(`Exited with ${code}`));
Output:
src/auth.js:12:// TODO: rotate keys
Exited with 0
execFile — safer than exec
exec runs the command through a shell, which makes shell-injection a real risk if any argument is user-controlled. Prefer execFile (no shell) and pass arguments as an array:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H"]);
console.log(stdout.trim());
Output:
c93883e9a6b7f1e5d4c3b2a1f0e9d8c7b6a5f4e3
fork — Node-to-Node IPC
fork spawns another Node script and gives both sides a .send() / 'message' channel — a structured-clone-based equivalent of postMessage.
// parent.js
import { fork } from "node:child_process";
const child = fork(new URL("./child.js", import.meta.url).pathname);
child.send({ task: "compute", value: 42 });
child.on("message", (msg) => {
console.log("Parent got:", msg);
child.disconnect();
});
// child.js
process.on("message", (msg) => {
if (msg.task === "compute") {
process.send({ result: msg.value * 2 });
}
});
Output:
Parent got: { result: 84 }
node:cluster — multi-process scaling
Workers share one event loop per process; cluster spins up N copies of your server process behind a shared port so you can use every CPU core. For most modern Node apps, prefer the built-in node --watch --env-file=... + a process manager (PM2, systemd) or container orchestrator, but cluster is still useful for embedded multi-core scaling.
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died; respawning`);
cluster.fork();
});
} else {
// Each worker runs the server
await import("./server.js");
console.log(`Worker ${process.pid} ready`);
}
Output:
Worker 12345 ready
Worker 12346 ready
Worker 12347 ready
Worker 12348 ready
Signals and graceful shutdown
POSIX signals (SIGINT, SIGTERM, etc.) arrive as events on process. Listening to them is how you implement graceful shutdown: stop accepting new connections, drain in-flight requests, flush logs, then exit.
import { setTimeout as sleep } from "node:timers/promises";
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}, shutting down...`);
await server.close(); // stop accepting new connections
await db.end(); // close pool
await Promise.race([drainInFlight(), sleep(10_000)]);
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT")); // Ctrl+C
process.on("SIGUSR2", () => shutdown("SIGUSR2")); // nodemon restart
Output:
Received SIGTERM, shutting down...
Common signals:
| Signal | Default | Used for |
|---|---|---|
SIGINT | Terminate | Ctrl+C in the terminal |
SIGTERM | Terminate | Container orchestrators on graceful stop |
SIGKILL | Terminate (uncatchable) | Force-kill — cannot be intercepted |
SIGHUP | Terminate | Terminal closed; some daemons reload config |
SIGUSR1 | Terminate | Reserved — Node uses it to enter debugger |
SIGUSR2 | Terminate | Free for app use; nodemon uses it for restart |
EventEmitter — Node's pub/sub
EventEmitter is the base class behind every streaming Node API (stream, http, process, child_process). It's a synchronous pub/sub primitive — listeners run in registration order, on the same tick as emit().
import { EventEmitter } from "node:events";
class Cache extends EventEmitter {
set(key, value) {
this.emit("set", { key, value });
}
}
const cache = new Cache();
cache.on("set", ({ key }) => console.log(`Wrote ${key}`));
cache.set("user:1", { name: "Alice" });
Output:
Wrote user:1
Async iteration with events.on / events.once
import { EventEmitter, once, on } from "node:events";
const em = new EventEmitter();
// Wait for a single emission
setTimeout(() => em.emit("ready", 42), 100);
const [value] = await once(em, "ready");
console.log(value);
// Stream emissions as an async iterator (until 'end' or AbortSignal fires)
const ac = new AbortController();
setTimeout(() => ac.abort(), 250);
try {
for await (const [n] of on(em, "tick", { signal: ac.signal })) {
console.log("tick", n);
}
} catch (err) {
if (err.name !== "AbortError") throw err;
}
Output:
42
Default maxListeners warning
Adding more than 10 listeners to one emitter prints a MaxListenersExceededWarning. Either raise the limit with emitter.setMaxListeners(n) or fix the leak — almost always the latter.
const em = new EventEmitter();
em.setMaxListeners(20);
Output: (none — exits 0 on success)
AbortSignal — cancellation across APIs
AbortSignal is the standard cancellation primitive — same shape in Node, browsers, Bun, and Deno. Every modern async Node API (fetch, setTimeout, events.on, readFile, pipeline) accepts a signal.
import { setTimeout as sleep } from "node:timers/promises";
const ac = new AbortController();
setTimeout(() => ac.abort(), 100);
try {
await sleep(1000, undefined, { signal: ac.signal });
} catch (err) {
if (err.name === "AbortError") console.log("Cancelled");
}
Output:
Cancelled
Combining signals
AbortSignal.any([sig1, sig2]) (Node 20+) returns a signal that aborts when any input aborts. Use it to combine a user-driven cancel with a per-operation timeout.
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(5000)]);
await fetch("/api/slow", { signal });
Output: (none — exits 0 on success)
ESM ↔ CJS interop deep dive
ESM and CJS can call into each other, but with sharp edges. The rules below assume Node 22+, which finished off most of the historical pain points (synchronous require(esm) arrived in 22.12).
ESM importing CJS
ESM can import a CJS module — the entire module.exports becomes the default export, and Node also tries to expose named exports via static analysis.
// legacy.cjs
module.exports = { greet: () => "hi", goodbye: () => "bye" };
// modern.mjs — both styles work
import legacy from "./legacy.cjs"; // legacy.greet, legacy.goodbye
import { greet } from "./legacy.cjs"; // named import (best-effort)
If named imports fail because Node can't infer them statically, use the namespace import and destructure:
import * as legacy from "./legacy.cjs";
const { greet } = legacy.default ?? legacy;
CJS importing ESM (Node 22+)
Until Node 22, this required await import(...). As of 22.12, synchronous require() of ESM is supported, gated behind --experimental-require-module until 24.
// commonjs-consumer.cjs
const esmMod = require("./modern.mjs"); // Node 22+ only
console.log(esmMod.greet());
Output:
hi
For older Node versions, use dynamic import (always returns a Promise):
// commonjs-consumer.cjs (works on every Node version)
(async () => {
const { greet } = await import("./modern.mjs");
console.log(greet());
})();
Output:
hi
Conditional exports
package.json exports lets one package ship both ESM and CJS, plus type definitions and edge-runtime builds, behind a single name. Resolution rules walk the keys in order — first match wins.
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
}
}
The
typescondition must come first in each branch — TypeScript only respects the first match.
Importing JSON
// ESM — import assertions / attributes
import config from "./config.json" with { type: "json" };
// CJS — require still works for JSON
const config = require("./config.json");
The legacy assert { type: "json" } syntax is deprecated in favour of with { type: "json" } (Node 22+ / browsers).
Process management and pids
process.pid, process.ppid, and process.platform are useful in supervisor scripts, structured logs, and platform-specific code paths. process.send exists only in forked children (or when the parent passes stdio: ["ipc"]).
console.log("pid:", process.pid);
console.log("parent pid:", process.ppid);
console.log("argv0:", process.argv0); // executable name as invoked
console.log("execPath:", process.execPath); // resolved path to node binary
Output:
pid: 12345
parent pid: 12344
argv0: node
execPath: /usr/local/bin/node
Node vs Bun vs Deno
A quick capability matrix for the three mainstream server-side JS runtimes. All three support standard fetch, ESM, and Web Streams.
| Capability | Node 22+ | Bun 1.x | Deno 2.x |
|---|---|---|---|
node:* modules | Yes (native) | Yes (compatibility shim, ~95%) | Yes (npm: + node: imports) |
| ESM by default | With "type":"module" | Yes | Yes |
| TypeScript without transpile | --experimental-strip-types | Yes (built-in) | Yes (built-in) |
| Built-in test runner | node --test | bun test | deno test |
| Built-in package manager | npm/pnpm/yarn | bun install (lockfile-aware) | deno add, deno.lock |
| Built-in bundler | No (use esbuild/Vite) | bun build | deno bundle (deprecated; use deno compile) |
| HTTP server perf vs Node | Baseline | ~2–4× faster (microbench) | ~1.5× faster (microbench) |
| Workers (CPU parallelism) | worker_threads | Worker | Worker |
| Foreign function interface | N-API (C++ addons) | bun:ffi | Deno.dlopen |
| Default permissions | Unrestricted | Unrestricted | Sandboxed (opt-in --allow-*) |
When to choose what:
- Node — when you want the safest, most-supported ecosystem and don't need the absolute fastest cold start.
- Bun — when you want one tool that handles install, build, run, and test, and you can tolerate occasional compatibility surprises.
- Deno — when you want first-class TypeScript, browser-style imports, and a security sandbox by default (a Worker-friendly choice for edge functions).
See the dedicated Bun and Deno pages for runtime-specific deep dives.
Common pitfalls
- Blocking the event loop — heavy sync work (
JSON.parseon a 50 MB string, regex backtracking, sync FS) freezes every concurrent request. Offload to a worker or stream the input. - Recursive
process.nextTick— starves I/O and timers indefinitely. UsesetImmediatefor "next tick" work. - Floating promises —
fetch(url).then(...)without anawaitor.catch()becomes an unhandled rejection. ESLint'sno-floating-promisesrule catches this. execwith user input — runs through a shell and is vulnerable to injection. UseexecFilewith an args array instead.- Forgetting
proc.kill()— child processes outlive the parent if you don't kill them. Hook intoexit/SIGTERMto clean up. - Mixing sync and async FS —
readFileSyncinside an HTTP handler blocks every concurrent request. Use the promises API. - CJS-only npm package in ESM — error
module is not defined. Either use dynamicimport()or add the module to theexportsmap with both conditions. require.cachedifferences — clearing the CJS cache to "hot reload" doesn't work for ESM. Use a loader hook or restart the process.process.on('exit')is synchronous-only — async work inside the handler is dropped. Useprocess.on('beforeExit')or do your async cleanup before callingprocess.exit.- Buffer pooling sharing memory — small
Buffer.allocUnsafe()instances may share the same underlying memory. UseBuffer.alloc(zeroed) for anything sensitive.
Real-world recipes
CPU-bound work without blocking the loop
Offload a slow function to a worker, with timeout and cancellation via AbortSignal.
import { Worker } from "node:worker_threads";
function runInWorker(task, { signal } = {}) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./worker.js", import.meta.url));
const onAbort = () => worker.terminate();
signal?.addEventListener("abort", onAbort, { once: true });
worker.once("message", (val) => {
signal?.removeEventListener("abort", onAbort);
resolve(val);
});
worker.once("error", reject);
worker.postMessage(task);
});
}
const result = await runInWorker(
{ url: "https://example.com" },
{ signal: AbortSignal.timeout(2000) }
);
console.log(result);
Output:
{ ok: true, size: 4096 }
Graceful HTTP shutdown
A drop-in shutdown handler for an http.Server. Combine with your framework's .close() for a complete drain.
import http from "node:http";
const server = http.createServer((req, res) => {
res.end("hello\n");
});
server.listen(3000);
const sockets = new Set();
server.on("connection", (s) => {
sockets.add(s);
s.on("close", () => sockets.delete(s));
});
async function shutdown() {
console.log("Closing HTTP server…");
server.close(() => console.log("Server closed"));
// Force-close idle keep-alives after 5 s
setTimeout(() => sockets.forEach((s) => s.destroy()), 5000).unref();
}
process.on("SIGTERM", shutdown);
Output:
Closing HTTP server…
Server closed
Parallel pipeline with worker pool
Process a list of items with bounded concurrency, isolating each task in a worker.
import Piscina from "piscina";
const pool = new Piscina({
filename: new URL("./image-resize-worker.js", import.meta.url).href,
maxThreads: 8,
});
const files = ["a.png", "b.png", "c.png", "d.png"];
const results = await Promise.all(files.map((f) => pool.run({ file: f, size: 256 })));
console.log(results);
await pool.destroy();
Output:
[ 'a@256.webp', 'b@256.webp', 'c@256.webp', 'd@256.webp' ]
Spawning a long-running child with abort
Use AbortController to kill a child process on a deadline.
import { spawn } from "node:child_process";
const ac = new AbortController();
const proc = spawn("ping", ["-c", "100", "example.com"], { signal: ac.signal });
setTimeout(() => ac.abort(), 3000);
proc.stdout.on("data", (b) => process.stdout.write(b));
proc.on("close", (code, signal) => {
console.log(`Exited code=${code} signal=${signal}`);
});
Output:
PING example.com (...): 56 data bytes
…
Exited code=null signal=SIGTERM
Module preload with --import
Register OpenTelemetry instrumentation before the entry point loads any application code.
// register.js
import { register } from "node:module";
register("@opentelemetry/auto-instrumentations-node");
node --import ./register.js dist/server.js
Output: (none — exits 0 on success)
Detecting the runtime
If you ship a library that wants to support Node, Bun, and Deno:
const runtime =
typeof Deno !== "undefined" ? "deno"
: typeof Bun !== "undefined" ? "bun"
: typeof process !== "undefined" && process.versions?.node ? "node"
: "unknown";
console.log("Running on:", runtime);
Output:
Running on: node