Node.js · cheat sheet

Node.js

Event loop phases, nextTick vs promises, the thread pool, workers, CommonJS vs ESM, streams, errors, shutdown, Express and security, checked on Node 22.

The Node.js facts interviewers probe, from event loop ordering to graceful shutdown. The core-module snippets and outputs were run on Node 22.

What Node is

  • V8 compiles and runs your JavaScript; libuv provides the event loop, non-blocking I/O (epoll, kqueue, IOCP) and a thread pool; C++ bindings join them to the core modules (fs, net, http, crypto).
  • Your JavaScript runs on one thread per event loop. I/O runs in the kernel or the thread pool, and its callbacks queue up for that thread.
  • Great for I/O-bound work: APIs, proxies, real-time apps, streaming. CPU-heavy work blocks every request unless you move it off the main thread.
  • The process exits when the loop has nothing left: no timers, sockets or pending requests. timer.unref() stops a handle from keeping it alive.

Event loop phases

Phase Runs
timers expired setTimeout / setInterval callbacks
pending callbacks some system callbacks deferred from the last iteration (certain TCP errors)
idle, prepare internal housekeeping
poll new I/O events and their callbacks; waits here when idle, until the next timer is due
check setImmediate callbacks
close callbacks 'close' handlers, such as after socket.destroy()
  • process.nextTick and promises are not phases. After the main script and after every callback, Node drains the nextTick queue, then the promise microtask queue, repeating until both are empty (phases in depth).
  • setTimeout(fn, 0) really means 1 ms. Timers set a minimum delay, never an exact one.

nextTick, promises & timers

console.log('sync 1');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('microtask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync 2');
// .cjs: sync 1, sync 2, nextTick, promise, microtask, then timeout/immediate in either order
// .mjs: sync 1, sync 2, promise, microtask, nextTick, then timeout/immediate in either order
JavaScript
  • CommonJS: nextTick runs before promise callbacks. ES modules flip it: top-level module code runs inside a promise job, so promise callbacks drain before Node reaches the nextTick queue.
  • From the main module, timeout vs immediate is a race (it depends on whether 1 ms has passed when the loop starts). Inside an I/O callback it is fixed:
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  process.nextTick(() => console.log('nextTick'));
});
// always: nextTick, immediate, timeout (poll → check → next turn's timers)
JavaScript
  • queueMicrotask(fn) shares the promise queue; the Node docs suggest it over nextTick for most code.
  • A recursive nextTick or microtask chain starves I/O: a setTimeout(0) only ran after a million recursive ticks had finished.

Thread pool & blocking the loop

  • Uses libuv’s pool: async fs APIs, dns.lookup() (used by http.get with a hostname), async crypto (pbkdf2, scrypt, randomBytes, randomFill, generateKeyPair) and async zlib.
  • Doesn’t: TCP/UDP sockets and HTTP (OS non-blocking I/O), dns.resolve*() (network queries).
  • Default size 4; set UV_THREADPOOL_SIZE (up to 1024) in the environment before start. Six async pbkdf2 calls: four finished at ~80 ms and two at ~150 ms; with a pool of 6, all six finished by ~110 ms.
  • Blockers: CPU loops, *Sync APIs in request handlers, JSON.parse/stringify of huge payloads, catastrophic regexes (ReDoS), giant sorts. Symptom: every request slows down and timers fire late.
  • Fixes: move CPU work to worker_threads; split work into chunks and yield with setImmediate; stream instead of loading everything; push slow jobs onto a queue. Measure with perf_hooks.monitorEventLoopDelay().

Workers, processes & cluster

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), { workerData: 40 });
  worker.once('message', (n) => console.log('fib =', n)); // main loop stays free
  worker.once('error', console.error);
} else {
  const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
  parentPort.postMessage(fib(workerData));
}
JavaScript
worker_threads child_process cluster
Runs a thread with its own V8 isolate and event loop any program in a new OS process forked Node processes sharing one server port
Memory separate heaps; share via SharedArrayBuffer, transfer ArrayBuffers fully separate fully separate
Talks via postMessage (structured clone) stdio pipes; IPC with fork() IPC
Use for CPU-bound JS: hashing, parsing, images shell tools, other languages, isolation using every core for an HTTP server
  • spawn streams output with no shell; execFile buffers output with no shell; exec runs through a shell and buffers (a big output fails with ERR_CHILD_PROCESS_STDIO_MAXBUFFER); fork starts a Node script with an IPC channel.
  • In containers, one process per container scaled by the orchestrator usually replaces cluster. Size pools with os.availableParallelism().

CommonJS vs ES modules

CommonJS ES modules
Syntax require(), module.exports import, export
Files .cjs, or .js without "type": "module" .mjs, or .js with "type": "module"
Loading synchronous, at run time, can be conditional static graph, linked before running; import() is async
Exports a value: destructured copies don’t update live, read-only bindings
Top-level await no yes
__dirname, require available import.meta.dirname/filename (20.11+), createRequire(import.meta.url)
Relative paths extension optional extension required
JSON require('./data.json') import data from './data.json' with { type: 'json' }
Strict mode opt in always
  • ESM importing CJS: module.exports is the default export; named imports work when Node’s static analysis detects them.
  • CJS loading ESM: await import() always works. require(esm) works without a flag since 22.12 (no warning since 22.13), but throws ERR_REQUIRE_ASYNC_MODULE if the graph uses top-level await.
  • Since 22.7, a typeless .js file with ESM syntax is re-run as ESM, with a warning and a reparse cost; set "type" explicitly.

package.json & dependencies

Field Purpose
type "module" or "commonjs" (default) for .js files
main / exports entry point / public entry points with conditions (import, require, types, default); exports blocks deep imports
scripts npm run x; npm test, npm start; pre/post hooks
bin CLI commands linked into node_modules/.bin
engines supported Node versions (advisory unless engine-strict)
files, private, workspaces, overrides publish list, block publishing, monorepos, force transitive versions
Range Matches Range Matches
^1.2.3 >=1.2.3 <2.0.0 ~1.2.3 >=1.2.3 <1.3.0
^0.2.3 >=0.2.3 <0.3.0 1.2.x, ~1.2 >=1.2.0 <1.3.0
^0.0.3 >=0.0.3 <0.0.4 1.2.3 exactly that
  • npm install x saves ^x.y.z. Prereleases don’t match unless the range names one: 1.3.0-beta.1 fails ^1.2.3.
  • Lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml) pin the whole tree with integrity hashes: commit them for apps. npm ci deletes node_modules, installs exactly the lockfile, and fails if it disagrees with package.json: use it in CI and Docker.
  • dependencies: needed at run time. devDependencies: build and test only (npm ci --omit=dev skips them). peerDependencies: the host app provides them (a React plugin peers on react); npm 7+ installs them. optionalDependencies: install failures are ignored.

EventEmitter & streams

  • emit() calls listeners synchronously, in registration order. on, once, off; events.once(emitter, 'ready') returns a promise; events.on() gives an async iterator.
  • An 'error' event with no listener throws and crashes the process; streams and servers are emitters too.
  • An 11th listener for one event logs MaxListenersExceededWarning (default max 10): usually a listener leak.
Stream Examples
Readable fs.createReadStream, HTTP req, process.stdin
Writable fs.createWriteStream, HTTP res, process.stdout
Duplex net.Socket
Transform zlib.createGzip(), crypto.createCipheriv()
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';

await pipeline(
  createReadStream('big.txt'),
  createGzip(),
  createWriteStream('big.txt.gz'),
); // rejects on any error and destroys every stream
JavaScript
  • Backpressure: write() returns false once the buffer passes highWaterMark (64 KiB for byte streams in Node 22, up from 16 KiB; 16 objects in object mode). Stop writing until 'drain': if (!out.write(chunk)) await once(out, 'drain').
  • pipe() handles backpressure but neither forwards errors nor cleans up on failure; pipeline() does both.
  • Readables are async iterables: for await (const chunk of stream). Readable.from(iterable) builds one.

Buffer & fs

  • Buffer is a Uint8Array subclass holding raw bytes. 'héllo'.length is 5, but Buffer.byteLength('héllo') is 6: use bytes for Content-Length.
  • Buffer.alloc(n) is zero-filled; Buffer.allocUnsafe(n) is faster but may hold old memory; new Buffer() is deprecated.
  • Encode: buf.toString('base64' | 'hex' | 'utf8'), Buffer.from(str, 'base64'). subarray() shares memory: writing to it changes the original.
  • Compare secrets with crypto.timingSafeEqual, not ===.
fs style Example Use when
sync fs.readFileSync(p) startup, CLIs; never in request handlers
callback fs.readFile(p, (err, data) => {}) legacy code (error-first)
promises await readFile(p, 'utf8') from node:fs/promises the default
streams fs.createReadStream(p) large files at constant memory
  • Build paths with path.join(import.meta.dirname, 'data.json'): relative paths resolve against the cwd, not the file.
  • Skip exists-then-open checks (a race); open it and handle err.code === 'ENOENT'.

Errors & graceful shutdown

  • Error-first callbacks: (err, result). Check err first and return; a throw inside a callback escapes any outer try.
  • async/await: try/catch around await; attach .catch() to promises you don’t await. new Error('msg', { cause }) keeps the original error.
  • Operational errors (ENOENT, ECONNRESET, timeouts, bad input) are handled; programmer errors (bugs) crash, and a supervisor restarts the process.
  • Unhandled rejections crash the process with exit code 1 (the throw mode, default since Node 15). A process.on('unhandledRejection') handler stops the crash.
  • process.on('uncaughtException') is a last resort: log, then exit; state is unknown after it.
  • process.exit() exits even with pending async work; prefer process.exitCode = 1 and let the loop drain.
process.on('SIGTERM', () => {
  server.close(async () => {              // stop accepting, wait for in-flight requests
    await db.close();                     // then close pools and consumers
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref(); // hard deadline
});
JavaScript
  • Since Node 19, server.close() also closes idle keep-alive connections; server.closeAllConnections() kills active ones too. Kubernetes and Docker send SIGTERM, then SIGKILL after the grace period.

Config, HTTP & Express

  • process.env values are strings ('false' is truthy): parse and validate them at startup and fail fast.
  • node --env-file=.env app.js loads a dotenv file (added in 20.6; no longer experimental since 22.21). Real environment variables win over the file; later files override earlier ones; --env-file-if-exists tolerates a missing file; process.loadEnvFile() does it from code.
  • NODE_ENV=production is a library convention (Express, bundlers); Node itself ignores it.
  • http.createServer((req, res) => {...}): req is a Readable, res a Writable; call res.end() or the request hangs.
import express from 'express';
const app = express();
app.use(express.json({ limit: '100kb' }));        // 1. parsers, logging, auth
app.get('/users/:id', async (req, res) => {       // 2. routes
  const user = await db.findUser(req.params.id);  // Express 5 forwards rejections
  if (!user) return res.status(404).json({ error: 'not found' });
  res.json(user);
});
app.use((req, res) => res.status(404).json({ error: 'no route' })); // 3. 404
app.use((err, req, res, next) => {                // 4. error handler: 4 args, last
  res.status(err.status ?? 500).json({ error: 'internal error' });
});
JavaScript
  • Middleware runs in registration order; each one calls next() or ends the response. next(err) skips to the error handlers.
  • Express 4 doesn’t catch rejected promises: wrap async handlers or call next(err) yourself. Express 5 forwards them. If res.headersSent, delegate with next(err).

Security checklist

  • Validate every input against a schema and limit body sizes. Use parameterized queries; block NoSQL operator injection ({ "$gt": "" }).
  • Never pass user input to eval, new Function or exec; use execFile/spawn with an argument array. The vm module is not a sandbox.
  • Path traversal: path.resolve(root, input) and check that it still starts with root.
  • Prototype pollution: don’t deep-merge untrusted JSON (__proto__, constructor); use Map, Object.create(null) or schemas.
  • Set security headers (e.g. helmet), an explicit CORS allowlist, rate limits, HTTPS, and HttpOnly/Secure/SameSite cookies.
  • Keep secrets in env vars or a secret manager, never in git or logs. Run as non-root.
  • Supply chain: commit the lockfile, npm ci, npm audit, review new packages, --ignore-scripts for untrusted installs.
  • Never expose --inspect beyond 127.0.0.1: the debugger allows remote code execution. The permission model (--permission, stable since 22.13) restricts fs, child process and worker access.

Profiling, testing & fetch

  • Debug: node --inspect app.js (127.0.0.1:9229), then open Chrome’s chrome://inspect; --inspect-brk pauses on the first line.
  • CPU: record a profile in DevTools or run with --cpu-prof (writes a .cpuprofile); look for wide frames in the flame chart.
  • Memory leaks: take heap snapshots (DevTools, v8.writeHeapSnapshot(), --heapsnapshot-signal=SIGUSR2, --heapsnapshot-near-heap-limit=3), compare two over time, follow the retainers. Usual suspects: unbounded caches, listeners never removed, uncleared timers, closures holding big objects.
  • Watch process.memoryUsage() and loop lag: a 200 ms busy loop showed up as ~210 ms max delay in monitorEventLoopDelay.
import { test } from 'node:test';
import assert from 'node:assert/strict';

test('adds', () => assert.equal(1 + 2, 3));
test('mocks', (t) => {
  const fn = t.mock.fn((x) => x * 2);
  fn(2);
  assert.equal(fn.mock.callCount(), 1);
});
JavaScript
  • node --test (stable since 20) finds files like *.test.js or under test/; add --watch, --test-name-pattern, --experimental-test-coverage.
  • Global fetch (built on undici) is stable since 21. It resolves on 404 and 500: check res.ok. Cancel with signal: AbortSignal.timeout(5000), which rejects with a TimeoutError.

CLI flags

Flag Does
--watch, --watch-path=src restart on file changes (stable since 22.0)
--env-file=.env load environment variables from a file
--test run the built-in test runner
--inspect[=host:port], --inspect-brk attach a debugger; -brk waits on line 1
--run build run a package.json script without npm (22+)
--import ./setup.mjs, -r ./setup.cjs preload an ES / CommonJS module
-e "code", -p "expr" eval; eval and print
--max-old-space-size=4096 V8 old-space limit in MiB
--enable-source-maps stack traces point at TS or bundled sources
--unhandled-rejections=strict|warn|none override the default throw mode
--trace-warnings, --trace-uncaught stack traces for warnings and throws
--cpu-prof, --heap-prof, --heapsnapshot-signal=SIGUSR2 profiling output
--permission, --allow-fs-read=… opt into the permission model
  • Type stripping is on by default since 22.18: node app.ts runs files with erasable types only (an enum throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
  • Env: NODE_OPTIONS="--max-old-space-size=4096" applies flags to every Node process; UV_THREADPOOL_SIZE sizes the pool.

Quick answers

  • Is Node single-threaded? Your JS runs on one thread; libuv’s pool and the OS do I/O in parallel, and worker_threads add JS threads.
  • nextTick vs setImmediate? nextTick runs before the loop continues; setImmediate runs in the next check phase. The names are backwards.
  • setTimeout(0) vs setImmediate? A race from the main module; inside an I/O callback, setImmediate always wins.
  • What blocks the loop? Synchronous CPU work; fix with workers, chunking or streaming.
  • Worker threads vs cluster? Workers offload CPU tasks inside one process; cluster runs one server process per core.
  • spawn vs exec vs fork? Streamed, no shell; buffered through a shell; a Node child with IPC.
  • require vs import? Synchronous, dynamic CommonJS vs static, live-binding ESM that supports top-level await.
  • ^ vs ~? ^ allows minor and patch updates (patch only below 1.0.0); ~ allows patch updates.
  • npm ci vs npm install? ci installs the lockfile exactly and never rewrites it; install resolves ranges and may update it.
  • What is backpressure? A fast producer overrunning a slow consumer; honor write()’s false and wait for 'drain', or use pipeline().
  • What is middleware? A (req, res, next) function in a chain that can modify the request, end the response or pass control on.
  • How do you handle a crash? Log, exit non-zero, let a supervisor (systemd, Kubernetes, PM2) restart; don’t resume after uncaughtException.
  • Buffer vs string? A Buffer is bytes; a string is UTF-16 text; convert with an explicit encoding.

Gotchas & traps

  • A delay above 2,147,483,647 ms (about 24.8 days) becomes 1 ms, with a TimeoutOverflowWarning.
  • require caches modules: every importer shares one instance, so module-level state is a singleton.
  • "type": "module" turns every .js file ESM, including configs that use require; rename those to .cjs.
  • Slow DNS or big fs batches can starve crypto and zlib, since they all share the 4-thread pool.
  • JSON.parse on a 50 MB body blocks everyone; stream-parse or cap the size.
  • fetch doesn’t reject on HTTP errors, and a body can be read only once.
  • An async function passed to emitter.on() or forEach isn’t awaited: a throw inside it is an unhandled rejection and crashes the process.
  • Don’t mix callback and promise styles in one API; util.promisify converts error-first functions.
esc