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:

bash
node

Output:

text
Welcome to Node.js v22.14.0.
Type ".help" for more information.
>

Useful REPL commands:

text
> .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:

text
> 2 + 2
4
> _ * 10
40
> 'hello'.toUpperCase()
'HELLO'
> _
'HELLO'

Multi-line in the REPL (Node detects incomplete expressions automatically):

text
> function greet(name) {
... return `Hello, ${name}!`;
... }
undefined
> greet('world')
'Hello, world!'

Running a script

bash
node script.js

Output: (none — exits 0 on success)

Passing arguments

Arguments after the script name are available in process.argv:

bash
node script.js foo bar

Output: (none — exits 0 on success)

javascript
// 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:

text
[
  '/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:

bash
node --watch server.js

Output:

text
(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:

bash
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:

bash
node --env-file=.env server.js

Output: (none — exits 0 on success)

text
# .env
PORT=3000
DATABASE_URL=postgres://localhost/mydb
javascript
// 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):

bash
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:

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

bash
node --experimental-strip-types server.ts

Output: (none — exits 0 on success)

bash
# 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

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

text
   ┌───────────────────────────────────────┐
┌─>│           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:

  1. process.nextTick queue — runs as soon as the current operation completes.
  2. 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

javascript
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:

text
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):

javascript
import { readFile } from "node:fs";

readFile(import.meta.url, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

Output:

text
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:

javascript
// 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

APIRuns in phaseStarves I/O?
process.nextTick(fn)Between phases (before microtasks)Yes if recursive
queueMicrotask(fn)Microtask queueYes if recursive
setImmediate(fn)check phaseNo (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:

json
// package.json — makes ALL .js files in the package ESM
{
  "type": "module"
}
javascript
// Use the .mjs extension — always ESM regardless of package.json
// math.mjs
export function add(a, b) { return a + b; }
javascript
// Use the .cjs extension — always CommonJS regardless of package.json
// util.cjs
module.exports = { greet: (name) => `Hello, ${name}` };

Import vs require

javascript
// 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)

javascript
// 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 / __filenameNot available (use import.meta.url)Available
Top-level awaitYesNo
Named exportsYesSimulated via object properties
Tree-shakeable by bundlersYesNo
Interop: ESM importing CJSYes (default import only)No (can't require() ESM)

import.meta in ESM (replaces __dirname)

javascript
// 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.

javascript
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):

text
[ '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.

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

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

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

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

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

javascript
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:

javascript
// 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()):

text
{
  rss: 38502400,
  heapTotal: 6291456,
  heapUsed: 4873616,
  external: 422056,
  arrayBuffers: 17382
}

Modern globals (no import needed)

fetch (v18+)

javascript
// 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:

text
107234

structuredClone (v17+)

Deep-clone any serializable value — replaces JSON.parse(JSON.stringify(obj)):

javascript
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+)

javascript
// 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.

javascript
globalThis.myGlobal = 'shared';
console.log(globalThis.myGlobal); // 'shared'

setTimeout / setInterval / clearTimeout / clearImmediate

javascript
// 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.

javascript
// 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();
});
javascript
// 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:

text
Got: b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9

Sharing memory with SharedArrayBuffer

For workloads where copying the input is too expensive, pass a SharedArrayBuffer and use Atomics for safe access:

javascript
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:

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

bash
npm install piscina

Output:

text
added 1 package in 1s
javascript
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:

text
{ 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.

FunctionReturnsWhen 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)BufferBuild scripts, one-off CLI
fork(modulePath)ChildProcess + IPCSpawn another Node script with process.send

spawn — streaming

javascript
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:

text
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:

javascript
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:

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

javascript
// 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();
});
javascript
// child.js
process.on("message", (msg) => {
  if (msg.task === "compute") {
    process.send({ result: msg.value * 2 });
  }
});

Output:

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

javascript
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:

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

javascript
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:

text
Received SIGTERM, shutting down...

Common signals:

SignalDefaultUsed for
SIGINTTerminateCtrl+C in the terminal
SIGTERMTerminateContainer orchestrators on graceful stop
SIGKILLTerminate (uncatchable)Force-kill — cannot be intercepted
SIGHUPTerminateTerminal closed; some daemons reload config
SIGUSR1TerminateReserved — Node uses it to enter debugger
SIGUSR2TerminateFree 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().

javascript
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:

text
Wrote user:1

Async iteration with events.on / events.once

javascript
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:

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

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

javascript
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:

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

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

javascript
// legacy.cjs
module.exports = { greet: () => "hi", goodbye: () => "bye" };
javascript
// 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:

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

javascript
// commonjs-consumer.cjs
const esmMod = require("./modern.mjs");   // Node 22+ only
console.log(esmMod.greet());

Output:

text
hi

For older Node versions, use dynamic import (always returns a Promise):

javascript
// commonjs-consumer.cjs (works on every Node version)
(async () => {
  const { greet } = await import("./modern.mjs");
  console.log(greet());
})();

Output:

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

json
{
  "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 types condition must come first in each branch — TypeScript only respects the first match.

Importing JSON

javascript
// 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"]).

javascript
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:

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

CapabilityNode 22+Bun 1.xDeno 2.x
node:* modulesYes (native)Yes (compatibility shim, ~95%)Yes (npm: + node: imports)
ESM by defaultWith "type":"module"YesYes
TypeScript without transpile--experimental-strip-typesYes (built-in)Yes (built-in)
Built-in test runnernode --testbun testdeno test
Built-in package managernpm/pnpm/yarnbun install (lockfile-aware)deno add, deno.lock
Built-in bundlerNo (use esbuild/Vite)bun builddeno bundle (deprecated; use deno compile)
HTTP server perf vs NodeBaseline~2–4× faster (microbench)~1.5× faster (microbench)
Workers (CPU parallelism)worker_threadsWorkerWorker
Foreign function interfaceN-API (C++ addons)bun:ffiDeno.dlopen
Default permissionsUnrestrictedUnrestrictedSandboxed (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

  1. Blocking the event loop — heavy sync work (JSON.parse on a 50 MB string, regex backtracking, sync FS) freezes every concurrent request. Offload to a worker or stream the input.
  2. Recursive process.nextTick — starves I/O and timers indefinitely. Use setImmediate for "next tick" work.
  3. Floating promisesfetch(url).then(...) without an await or .catch() becomes an unhandled rejection. ESLint's no-floating-promises rule catches this.
  4. exec with user input — runs through a shell and is vulnerable to injection. Use execFile with an args array instead.
  5. Forgetting proc.kill() — child processes outlive the parent if you don't kill them. Hook into exit/SIGTERM to clean up.
  6. Mixing sync and async FSreadFileSync inside an HTTP handler blocks every concurrent request. Use the promises API.
  7. CJS-only npm package in ESM — error module is not defined. Either use dynamic import() or add the module to the exports map with both conditions.
  8. require.cache differences — clearing the CJS cache to "hot reload" doesn't work for ESM. Use a loader hook or restart the process.
  9. process.on('exit') is synchronous-only — async work inside the handler is dropped. Use process.on('beforeExit') or do your async cleanup before calling process.exit.
  10. Buffer pooling sharing memory — small Buffer.allocUnsafe() instances may share the same underlying memory. Use Buffer.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.

javascript
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:

text
{ 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.

javascript
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:

text
Closing HTTP server…
Server closed

Parallel pipeline with worker pool

Process a list of items with bounded concurrency, isolating each task in a worker.

javascript
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:

text
[ '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.

javascript
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:

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

javascript
// register.js
import { register } from "node:module";
register("@opentelemetry/auto-instrumentations-node");
bash
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:

javascript
const runtime =
  typeof Deno !== "undefined" ? "deno"
  : typeof Bun !== "undefined" ? "bun"
  : typeof process !== "undefined" && process.versions?.node ? "node"
  : "unknown";

console.log("Running on:", runtime);

Output:

text
Running on: node