Event loop phases, nextTick vs promises, the thread pool, workers, CommonJS vs ESM, streams, errors, shutdown, Express and security, checked on Node 22.
AMCompiled by Aditya Mishra · Technical Lead, BNP Paribas
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:
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().
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
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
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.
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.