skip to content

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.

24 min read 99 snippets deep dive

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:

  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#

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#

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:

// 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 / __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)#

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

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#

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:

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

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"
  }
}

[!WARNING] The types condition 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.

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.

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