Skip to main content

Node.js Interview Questions (2026)

100 real interview questions with in-depth answers — 30 basic, 40 intermediate, 30 advanced. Updated April 2026.

Preparing for a Node.js Developer role?

Node.js is a server-side JavaScript runtime built on Chrome's V8 engine and the libuv async I/O library. Unlike browser JS, Node.js has no DOM, `window`, or browser APIs; instead it exposes OS-level modules like `fs`, `net`, `child_process`, and `http`. Node.js uses CommonJS modules by default (though ESM is now fully supported), runs as a single process on the server, and is designed for I/O-heavy workloads like web servers and CLI tools. Node.js also exposes globals such as `process`, `Buffer`, and `__dirname` that do not exist in browsers.

The event loop is the mechanism that allows Node.js to perform non-blocking I/O despite running on a single thread. It continuously cycles through phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks. During the poll phase it blocks waiting for I/O events if no timers are due, then drains ready callbacks. Between each phase, Node drains the `process.nextTick` queue and the microtask (Promise) queue. This means async callbacks never interrupt synchronous code — they queue up and execute only when the call stack is empty.

Non-blocking I/O means that operations like reading a file or making a network request are initiated and then control returns immediately to the caller — the result is delivered later via a callback, Promise, or event. Under the hood, libuv either delegates to the OS's async API (e.g., epoll on Linux) or offloads the work to a thread pool, then posts a completion event back to the event loop. This lets a single Node.js process handle thousands of concurrent connections without creating a thread per connection.

`require()` is CommonJS (CJS): synchronous, dynamic (can be called anywhere in code), and resolved at runtime. `import` is ES Module (ESM): statically analysed, asynchronous, and hoisted. In Node.js, CJS files use `.js` (with no `"type"` field or `"type":"commonjs"`) and ESM files use `.mjs` or `.js` with `"type":"module"` in `package.json`. CJS exports are objects (`module.exports`), while ESM uses named and default exports. Dynamic `import()` works in both systems and always returns a Promise.

js
// CJS
const fs = require('fs');
// ESM
import { readFile } from 'node:fs/promises';

`exports` is simply a reference that initially points to the same object as `module.exports`. You can safely add properties to `exports` (e.g., `exports.foo = 1`) and they appear on `module.exports`. However, if you reassign `exports` entirely (`exports = function() {}`), you break the reference — the module still exports the original `module.exports` object. To export a function or class as the whole module value you must use `module.exports = myFunction`.

`__dirname` is the absolute path of the directory containing the current module file; `__filename` is the absolute path of the current file itself. Both are injected by the CommonJS module wrapper function. In ES Modules these globals do not exist — you recreate them using `import.meta.url` and the `path`/`url` modules: ```js import { fileURLToPath } from 'node:url'; import path from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); ```
The `process` object is a global that provides information about the current Node.js process. Key properties include `process.argv` (CLI arguments), `process.env` (environment variables), `process.pid` (process ID), `process.cwd()` (current working directory), `process.exit(code)` (terminate the process), and `process.stdout`/`process.stderr`/`process.stdin` (standard streams). It also emits events like `exit`, `uncaughtException`, and `SIGTERM`.
`process.env` is a plain object containing the environment variables inherited by the Node.js process from the OS shell. It is the standard way to pass secrets (API keys, DB URLs) and runtime configuration (NODE_ENV, PORT) to a server without hard-coding them. Values are always strings — numeric values must be parsed with `Number()` or `parseInt()`. Libraries like `dotenv` load `.env` files into `process.env` at startup. In production, variables are set via the hosting platform (e.g., Vercel environment settings).
Node.js ships with a rich standard library. Key modules include: `fs` and `fs/promises` (file system), `path` (cross-platform path manipulation), `http`/`https` (create servers and make requests), `os` (OS info like CPU count, free memory), `crypto` (hashing, HMAC, encryption), `events` (`EventEmitter` base class), `stream` (readable/writable/transform streams), `child_process` (spawn subprocesses), `worker_threads` (true threads), `net` (TCP), `url`/`querystring` (URL parsing), and `util` (promisify, inspect). Prefer importing them with the `node:` prefix (e.g., `import fs from 'node:fs'`) to make built-in intent explicit.
A callback is a function passed as an argument to another function, to be called when an asynchronous operation completes. Before Promises became standard, callbacks were the primary async pattern in Node.js. The calling function begins an async task and immediately returns; when the task finishes, the runtime invokes the callback with the result. Deeply nested callbacks create "callback hell" — hard-to-read, hard-to-error-handle pyramids of code that Promises and `async/await` were designed to replace.
By convention, Node.js async callbacks receive the error as their first argument and the result as the second. If the operation succeeded, the first argument is `null` or `undefined`; if it failed, it's an `Error` object. You must always check the first argument before using the result. ```js fs.readFile('file.txt', 'utf8', (err, data) => { if (err) return console.error(err); console.log(data); }); ``` This convention is consistent across the entire Node.js core API and most older npm packages. `util.promisify()` converts any error-first callback function into one that returns a Promise.
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states: pending, fulfilled, or rejected. You chain `.then()` for success and `.catch()` for errors, avoiding nested callback pyramids. Node.js core began exposing Promise-based APIs via `fs/promises`, `dns/promises`, etc. `util.promisify()` wraps legacy callback APIs. `Promise.all()` runs tasks in parallel; `Promise.allSettled()` waits for all even if some reject. ```js import { readFile } from 'node:fs/promises'; const data = await readFile('file.txt', 'utf8'); ```
`async/await` is syntactic sugar over Promises that lets you write asynchronous code in a linear, synchronous style. An `async` function always returns a Promise. Inside it, `await` pauses execution until the awaited Promise settles, then resumes with the resolved value. Errors are caught with standard `try/catch`. Node.js supports top-level `await` in ES Modules (`.mjs` or `"type":"module"`). Top-level `await` blocks the module from finishing its evaluation until the Promise resolves, which affects other modules importing it.
`EventEmitter` is the base class (from `node:events`) for objects that emit named events and allow listeners to subscribe. Many core Node.js classes extend it — `Stream`, `http.Server`, `child_process.ChildProcess`, etc. You emit events with `.emit(name, ...args)` and listen with `.on(name, handler)` or `.once()` for one-time listeners. `.removeListener()` / `.off()` clean up handlers to prevent memory leaks. ```js import { EventEmitter } from 'node:events'; const bus = new EventEmitter(); bus.on('data', chunk => console.log(chunk)); bus.emit('data', 'hello'); ```
Use `http.createServer()` with a callback that receives `IncomingMessage` (req) and `ServerResponse` (res). Call `res.writeHead()` to set status and headers, then `res.end()` to send the body. ```js import http from 'node:http'; const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }); server.listen(3000, () => console.log('Listening on :3000')); ``` In production you'd use Express or Fastify on top of this, but understanding the raw `http` module is fundamental to understanding how frameworks work.
`npm` (Node Package Manager) is the default package manager for Node.js. It installs packages from the npm registry, manages scripts, and publishes packages. `package.json` is the manifest for a Node.js project: it records the project name, version, entry point (`main` / `exports`), `scripts` (runnable with `npm run`), `dependencies`, `devDependencies`, `peerDependencies`, and configuration for tools. Running `npm install` reads `package.json`, installs all listed packages into `node_modules`, and writes a lockfile.
`package-lock.json` records the exact resolved version of every package (and its transitive dependencies) that was installed. It guarantees reproducible installs across machines and CI environments — everyone gets the same dependency tree regardless of which patch-level releases have since been published. You should commit `package-lock.json` to version control. `npm ci` (used in CI) installs strictly from the lockfile, errors if it is out of sync with `package.json`, and never modifies it, making builds deterministic.
`dependencies` are packages required to run the application in production (e.g., Express, Stripe SDK). `devDependencies` are only needed during development and testing (e.g., Jest, TypeScript, ESLint, nodemon). When you `npm install --production` or set `NODE_ENV=production`, npm skips `devDependencies`, keeping the production bundle leaner. Docker production images should install only production dependencies to reduce image size and attack surface.
`npx` is a package runner bundled with npm 5.2+. It executes a binary from a locally installed npm package (in `node_modules/.bin`) or downloads and runs it temporarily if it's not installed, without permanently adding it to your project. Common uses: `npx create-next-app`, `npx prisma migrate`, running one-off CLI tools without globally installing them. `npx` also ensures you always run the version pinned in your project rather than a globally installed version that might differ.
`npm install` reads `package.json`, resolves the dependency tree, updates `package-lock.json` if needed, and installs packages. `npm ci` reads only `package-lock.json`, errors if the lockfile is missing or out of sync with `package.json`, deletes `node_modules` before installing, never writes to the lockfile, and is significantly faster in clean environments. Use `npm install` during development (to update locks) and `npm ci` in CI/CD pipelines for fast, deterministic, reproducible installs.
`nodemon` is a development utility that watches your project files for changes and automatically restarts the Node.js server. Without it, you must manually stop and restart the process every time you edit a file. Install it as a `devDependency` and add it to your `dev` script: `"dev": "nodemon src/index.js"`. Node.js 18+ introduced `--watch` as a built-in alternative (`node --watch src/index.js`), reducing the need for nodemon in newer projects.
The modern approach uses `fs/promises` with `async/await`. For small files you can read the whole thing into memory; for large files use a readable stream. ```js import { readFile } from 'node:fs/promises'; try { const data = await readFile('config.json', 'utf8'); const config = JSON.parse(data); } catch (err) { console.error('Read failed:', err.message); } ``` The older callback-based `fs.readFile` still works and is useful when you need maximum compatibility. Specify the encoding (`'utf8'`) to get a string; omit it to get a raw `Buffer`.
Environment variables are accessed via `process.env.VARIABLE_NAME`. In development, the `dotenv` package loads a `.env` file at startup: `require('dotenv').config()` or `import 'dotenv/config'`. The `.env` file is listed in `.gitignore` and never committed. In production, variables are set by the hosting platform (Vercel, AWS, Heroku) or injected via the shell. Node.js 20.6+ has built-in `.env` loading: `node --env-file=.env server.js`, removing the need for `dotenv` in many cases.
A `Buffer` is a fixed-size chunk of memory outside the V8 heap, used to work with binary data — network packets, file contents, crypto outputs — before it's decoded to a string. Create Buffers with `Buffer.from(data, encoding)`, `Buffer.alloc(size)` (zero-filled, safe), or `Buffer.allocUnsafe(size)` (faster, uninitialized). Convert to string with `buf.toString('utf8')` or `buf.toString('base64')`. ```js const buf = Buffer.from('hello', 'utf8'); console.log(buf.toString('hex')); // 68656c6c6f ```
Streams are objects that let you read or write data piece by piece (in chunks) rather than loading everything into memory at once. There are four types: `Readable` (source: file read, HTTP request body), `Writable` (sink: file write, HTTP response), `Duplex` (both: TCP socket), and `Transform` (reads, transforms, writes: gzip, encryption). Streams are `EventEmitter`s — you listen for `'data'`, `'end'`, and `'error'` events. For large files, videos, or log ingestion, streaming dramatically reduces memory usage and time-to-first-byte.
`setImmediate` schedules its callback in the **check phase** of the event loop, after I/O callbacks. `setTimeout(fn, 0)` schedules in the **timers phase**, with a minimum delay of ~1ms. When both are called from the main module (outside I/O), execution order is non-deterministic. When called from within an I/O callback, `setImmediate` always fires first because the check phase comes before the timers phase in the next iteration. Use `setImmediate` to defer work until after I/O without a timer delay.
`process.nextTick` schedules a callback to run at the end of the current operation, before the event loop moves to the next phase — even before I/O callbacks and `setImmediate`. It drains its queue entirely before the event loop continues, which means deeply recursive `nextTick` calls can starve I/O. Use it when you need to fire a callback asynchronously but before any I/O — for example, to emit an error event after a constructor has finished setting up listeners. For most deferral needs, prefer `queueMicrotask()` or a resolved `Promise`.
REPL stands for Read-Eval-Print Loop — an interactive shell you start by running `node` with no arguments. It reads a line of JavaScript, evaluates it in the current context, prints the result, and loops. The REPL is useful for quick experiments, inspecting APIs, and prototyping expressions. It supports multi-line input (automatically detected or forced with `.editor`), tab completion, `_` for the last result, `.help`, `.break`, `.load`/`.save`, and `.exit`. You can also create a custom REPL programmatically with `require('node:repl').start()`.
Uncaught exceptions in synchronous code crash the process by default. You can listen with `process.on('uncaughtException', handler)`, but best practice is to log the error and **still exit** — the process may be in an inconsistent state. For unhandled Promise rejections, listen to `process.on('unhandledRejection', handler)`. Node.js 15+ throws unhandled rejections as uncaught exceptions by default. In production, use a process supervisor (PM2, systemd) to restart the process after a crash, and structured logging to capture the error before exiting.
The `--inspect` flag starts Node.js with the V8 inspector protocol enabled on `127.0.0.1:9229` (by default). Chrome DevTools can then connect at `chrome://inspect` and gives you a full debugger: breakpoints, call stack, watched expressions, memory heap snapshots, and CPU profiling. `--inspect-brk` also breaks on the first line of the script so you can set breakpoints before any code runs. VS Code's built-in debugger connects via the same protocol — configured with a `launch.json` entry.
The event loop has six phases executed in order each iteration. **Timers**: runs callbacks scheduled by `setTimeout` and `setInterval` whose thresholds have expired. **Pending callbacks**: executes I/O callbacks deferred from the previous iteration (e.g., some TCP errors). **Idle/Prepare**: internal only, used by libuv. **Poll**: retrieves new I/O events; if no timers are due it blocks here until I/O arrives or a timer threshold is reached. **Check**: `setImmediate` callbacks run here. **Close callbacks**: handles `'close'` events (e.g., socket close). Between every phase transition, Node.js drains the `process.nextTick` queue and then the microtask (Promise `.then`) queue — both completely before resuming the loop.
libuv maintains a thread pool (default size 4, max 1024) for operations the OS does not support asynchronously: file system operations (`fs.*`), DNS lookups via `getaddrinfo`, crypto operations (`crypto.pbkdf2`, `crypto.randomBytes`), and user-space work via `worker_threads` and `child_process`. Network I/O is NOT in the thread pool — it uses the OS async APIs (epoll/kqueue/IOCP). Set `UV_THREADPOOL_SIZE` before startup to increase the pool: ```bash UV_THREADPOOL_SIZE=16 node server.js ``` A saturated thread pool causes fs/dns operations to queue behind each other, adding latency invisible to the event loop. Monitor it with `clinic doctor`.
Node.js runs JavaScript on a single thread. A CPU-intensive task (image resizing, bcrypt hashing, large JSON parsing) blocks the event loop for its entire duration — no other requests are served, no I/O callbacks fire, and the process appears frozen. Options to offload: (1) **Worker Threads** (`node:worker_threads`) — spawn a thread sharing the same process memory, ideal for pure computation. (2) **child_process.fork** — spawn a separate Node.js process; communicate via IPC; more isolation but heavier. (3) **Native addons** using N-API for hot loops. (4) Offload to a job queue (BullMQ + Redis) so separate worker processes handle heavy tasks asynchronously.
**Worker Threads** live inside the same Node.js process, share the V8 heap by passing `SharedArrayBuffer`, and communicate via `postMessage`. Startup is fast (~5ms). Best for compute tasks needing shared memory. **`child_process.fork`** spawns a fresh Node.js process with its own heap, communicates via IPC (serialized JSON). Best for isolated work or running a different script. **Cluster** forks multiple copies of the same HTTP server script, each binding the same port via an OS round-robin. The master/primary distributes incoming connections. Best for multi-core HTTP scaling. Cluster workers are `fork` processes — they share no memory and each run a full event loop.
The cluster module lets you fork `os.cpus().length` worker processes that all listen on the same port. The primary process accepts connections and distributes them to workers using round-robin scheduling (default on all platforms except Windows). Each worker is a separate Node.js process with its own event loop and V8 heap — no shared memory. Workers communicate with the primary via IPC. If a worker crashes, the primary can respawn it. In production, PM2 cluster mode wraps this with zero-downtime reloads: workers are replaced one at a time so the server never fully restarts. ```js import cluster from 'node:cluster'; import os from 'node:os'; if (cluster.isPrimary) { os.cpus().forEach(() => cluster.fork()); } else { startServer(); } ```
Node.js has four stream types: **Readable** (source), **Writable** (sink), **Duplex** (both, e.g., TCP socket), **Transform** (duplex that transforms data, e.g., `zlib.createGzip()`). Backpressure is the mechanism that prevents a fast Readable from overwhelming a slow Writable. When `writable.write(chunk)` returns `false`, the internal buffer is full; you must pause the readable and wait for the Writable's `'drain'` event before resuming. Using `readable.pipe(writable)` or `stream.pipeline()` handles backpressure automatically. Ignoring backpressure causes unbounded memory growth as buffered chunks accumulate in the Writable's internal queue.
`stream.pipeline()` (from `node:stream/promises`) chains streams and handles: (1) **automatic cleanup** — if any stream in the chain emits an error or the pipeline is aborted, all streams are destroyed, preventing resource leaks; (2) **error propagation** — the returned Promise rejects with the first error from any stream; (3) **backpressure** is respected throughout. Manual `.pipe()` does not propagate errors — an error on one stream leaves the others open and leaking file handles or connections. ```js import { pipeline } from 'node:stream/promises'; import { createReadStream, createWriteStream } from 'node:fs'; import { createGzip } from 'node:zlib'; await pipeline(createReadStream('in.log'), createGzip(), createWriteStream('in.log.gz')); ```
`Buffer` is a subclass of `Uint8Array` (a TypedArray), so it inherits all TypedArray methods. The key differences: Buffers are allocated from a pooled `ArrayBuffer` for small sizes (< 4KB), making frequent small allocations cheap. Buffers add Node.js-specific methods like `.toString(encoding)`, `.readUInt32BE()`, `.copy()`, `.compare()`, and static factory methods like `Buffer.from()`. TypedArrays can represent 16-bit, 32-bit, and float types; `Buffer` is always 8-bit. In `worker_threads.postMessage`, both Buffer and TypedArray can be transferred zero-copy with the transfer list.
In the raw `http` module, the server callback fires once per request with `(req, res)`. You manually inspect `req.method`, `req.url`, parse the body by consuming the readable stream, and call `res.end()`. There is no routing, body parsing, or error handling built in. Express wraps this: it creates an internal middleware stack, adds `req.params`/`req.body`/`req.query`, and iterates the stack by calling `next()`. Each middleware can modify `req`/`res` or send a response. This means Express adds ~1–2 layers of indirection but saves enormous boilerplate. Fastify takes a different approach: it compiles routes into a radix tree and uses JSON Schema-based serialization for ~3× throughput over Express.
Express middleware is a function with signature `(req, res, next)`. The app maintains an ordered stack of middleware registered with `app.use()`, `app.get()`, etc. When a request arrives, Express walks the stack calling each middleware. Calling `next()` advances to the next middleware; calling `next(err)` skips to the first error-handling middleware. Error middleware has the special 4-argument signature `(err, req, res, next)` — Express only routes to it when `next` is called with an argument. ```js app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: err.message }); }); ``` Always define error middleware last, after all routes.
`express.Router()` creates a mini-app — an isolated middleware stack with its own `use()`, `get()`, `post()`, etc. You mount it on the parent app with `app.use('/api/v1', router)`. This lets you split route handlers across multiple files, each managing their own prefix, middleware, and error handling independently. Routers can be nested. A common pattern is one Router per resource (usersRouter, ordersRouter) each in its own file, keeping `app.ts` lean. Routers also support `router.param()` to pre-process named parameters.
`async_hooks` is a core module that lets you track the lifetime of asynchronous resources — every time an async resource is created (init), starts executing (before), finishes executing (after), or is garbage collected (destroy). It solves the problem of "async context" — knowing which request or trace context a callback belongs to, even across Promises, timers, and I/O callbacks. Before `async_hooks`, libraries like `continuation-local-storage` used monkey-patching with unreliable results. `async_hooks` is the official low-level API; for application code use `AsyncLocalStorage` which builds on top of it.
`AsyncLocalStorage` (from `node:async_hooks`) provides request-scoped storage that automatically propagates through the entire async call chain — Promises, `await`, timers, callbacks — without passing context objects as arguments. It replaced the fragile `cls-hooked` library which patched Node.js internals. A common use case is storing the current user or trace ID for the lifetime of an HTTP request. ```js import { AsyncLocalStorage } from 'node:async_hooks'; const requestCtx = new AsyncLocalStorage(); app.use((req, res, next) => requestCtx.run({ userId: req.user.id }, next)); function getUser() { return requestCtx.getStore()?.userId; } ```
V8 uses a generational garbage collector. The heap is split into **new space** (young generation, ~1–8MB) for short-lived objects — collected by a fast "scavenger" (minor GC). Objects that survive two scavenges are promoted to **old space** (major GC, mark-sweep-compact). Large objects go directly to **large object space**. Code and metadata have their own spaces. The default heap limit is ~1.5GB on 64-bit systems (configurable with `--max-old-space-size=4096`). V8 heuristics trigger major GC when old space is >70% full. Major GC pauses the event loop — large heaps mean longer pauses.
Typical approach: (1) **Monitor RSS/heap** with `process.memoryUsage()` — continuously growing `heapUsed` suggests a leak. (2) Force GC with `node --expose-gc` and call `global.gc()` between samples to distinguish retained from pending-collection objects. (3) Take heap snapshots with `v8.writeHeapSnapshot()` or Chrome DevTools and diff two snapshots to find growing object sets. (4) Use `clinic.js` (`clinic heap`) or `heapdump` for automated snapshots. (5) Look for: unbounded caches, event listeners not removed (MaxListenersExceededWarning), closures capturing large objects, growing arrays never cleared.
`require.cache` is a plain object mapping resolved file paths to their module objects. Once a module is loaded it lives in the cache forever — subsequent `require()` calls return the cached export without re-executing the file. In tests, stale module state (singletons, DB connections) can leak between test files. To bust the cache: `delete require.cache[require.resolve('./myModule')]`. Jest's `jest.resetModules()` and `jest.isolateModules()` automate this. In ESM there is no equivalent cache-busting mechanism — you must use dependency injection or dynamic imports with cache-busting query strings.
A circular require occurs when `a.js` requires `b.js` which requires `a.js` before `a.js` has finished exporting. Node.js returns the **partially-complete** `module.exports` at the point of the cycle — properties added after the `require()` call are missing, causing subtle `undefined` bugs. To avoid: (1) Restructure so dependencies flow one way. (2) Move the circular `require()` inside the function that uses it (lazy require) — by then both modules have finished initialising. (3) Extract the shared code into a third module that both depend on.
Node.js treats `.mjs` files as ES Modules regardless of `package.json`. Setting `"type":"module"` in `package.json` makes all `.js` files in that package ESM; `.cjs` explicitly opts back to CJS. ESM uses static `import`/`export`, async evaluation (a module can `await` at the top level), and strict mode by default. **CJS → ESM**: CJS can dynamic-`import()` an ESM module. **ESM → CJS**: ESM can `import` CJS modules, which are exposed as a default export (named exports via static analysis sometimes work). The big gotcha: you cannot `require()` an ESM module — it's `import()` only.
Since Node.js 14.18+/16+, built-in modules can be imported with the `node:` prefix (e.g., `import fs from 'node:fs'`). The prefix unambiguously marks an import as a built-in, preventing shadowing by a same-named npm package (a supply-chain attack vector). It also slightly speeds up resolution since Node.js skips the `node_modules` lookup. The `node:` prefix is recommended in all new code and required in some contexts like `worker_threads` `workerData`. ESLint's `n/prefer-node-protocol` rule can enforce it project-wide.
Native addons are `.node` files — shared libraries compiled from C/C++ — that Node.js loads with `require()`. They allow tight integration with OS APIs, hardware, or performance-critical code (e.g., `bcrypt`, `sharp`, `sqlite3`). N-API (now called Node-API) is a stable ABI layer introduced in Node.js 8: addons built against it work across Node.js versions without recompilation. The `node-addon-api` package provides a C++ wrapper over N-API. Building: `node-gyp configure && node-gyp build`. `napi_*` functions handle JS values, objects, functions, and async workers that run on the libuv thread pool.
`vm.runInNewContext(code, sandbox)` compiles and runs a string of JavaScript in a new V8 context with a fresh global object. The `sandbox` object becomes the global — any globals the script sets are properties of the sandbox, isolated from the main context. It is used to evaluate untrusted scripts, implement templating engines, or test code in isolation. However it is **not a true security sandbox**: the `vm` module does not prevent access to the Node.js process. Passing a prototype-polluted object or a reference to a native function can break out. For real sandboxing use `worker_threads` or a separate child process.
`exec(command)` passes the command string to the OS shell (`/bin/sh -c`), which interprets shell metacharacters. If any part of the command string comes from user input, it is trivially injectable: `; rm -rf /` or backtick subshells can execute arbitrary commands. `execFile(file, args)` invokes the program directly without a shell — arguments are passed as an array and never parsed by a shell, so injection is impossible. Always prefer `execFile` (or `spawn`) when any argument is user-controlled. `execFileSync` and `spawnSync` are the synchronous equivalents.
`http.Agent` manages a pool of persistent TCP connections to each host:port pair. With keep-alive, a connection is reused for multiple HTTP/1.1 requests, eliminating the TCP and TLS handshake overhead on every call. By default, `http.globalAgent` creates a new agent with `keepAlive: false`. For high-throughput service-to-service calls, create a custom agent: `new http.Agent({ keepAlive: true, maxSockets: 50 })`. Each pool has `maxSockets` (default 5 per host) and `maxFreeSockets`. Under Node.js 18+, the built-in `fetch` uses undici with its own connection pool that has better defaults.
Node.js uses the c-ares library for async DNS lookups. `dns.lookup()` uses the OS resolver (`/etc/hosts`, `/etc/resolv.conf`, NSS) and is subject to OS-level caching — it runs on the libuv thread pool (not truly async at the OS level on all platforms). `dns.resolve4()` / `dns.resolve()` use c-ares to query DNS servers directly and are fully async. For HTTP connections, Node.js calls `dns.lookup()` by default, which can become a bottleneck under high concurrency if the thread pool is saturated. Set `dns.setDefaultResultOrder('ipv4first')` to prefer IPv4 in Node.js 17+.
Graceful shutdown ensures in-flight requests complete before the process exits. Listen for `SIGTERM` (sent by container orchestrators like Kubernetes). Stop accepting new connections with `server.close(callback)` — this closes the listening socket but keeps existing connections alive until they finish. For keep-alive connections you may need `server.closeAllConnections()` (Node 18.2+) or a timeout. Then close DB pools, flush logs, and call `process.exit(0)`. ```js process.on('SIGTERM', () => { server.close(() => { db.end().then(() => process.exit(0)); }); setTimeout(() => process.exit(1), 10_000).unref(); }); ```
Kubernetes distinguishes **liveness** (is the process alive?) and **readiness** (is it ready to serve traffic?). A liveness endpoint (`GET /healthz`) simply returns 200 to confirm the process is not deadlocked. A readiness endpoint (`GET /ready`) checks that dependencies — DB, Redis, external services — are reachable before returning 200; during startup or rolling deploys, it returns 503 to keep the pod out of load balancer rotation. Implement them on a separate internal port so they are never exposed to the public internet, and exclude them from request logging to keep logs clean.
**In-memory** (single instance): use `node-rate-limiter-flexible` or a simple sliding-window Map. Fast (no network), but per-instance state — in a multi-process cluster, limits are not shared. **Redis-backed** (multi-instance): store counters in Redis with `INCR`+`EXPIRE` or sorted sets for sliding windows; `rate-limiter-flexible` supports this. Redis atomic operations prevent races. At the HTTP layer, apply limiting in an Express/Fastify middleware before routing, key by `req.ip` or `req.headers['x-api-key']`. Always add `Retry-After` and `X-RateLimit-*` headers in 429 responses.
`node --watch` is a built-in file watcher that restarts the process when any file it has loaded via `require`/`import` changes. It is the built-in replacement for `nodemon` for simple use cases. Run `node --watch src/index.js` — no extra package needed. `--watch-path` lets you specify additional directories not automatically watched. For TypeScript projects you would combine it with `tsx` or `ts-node`: `node --watch --loader tsx src/index.ts`. It uses native OS file watchers (`inotify`/`kqueue`/`FSEvents`) for efficiency.
**ts-node**: transpiles TypeScript on-the-fly using the TypeScript compiler; respects `tsconfig.json` but is slow on large projects. **tsx**: uses esbuild under the hood for very fast (10–100×) transpilation; does not type-check. Run: `tsx src/index.ts`. Add `"dev": "tsx watch src/index.ts"` for watch mode. **`--experimental-strip-types`** (Node.js 22): Node.js itself strips type annotations using a lightweight parser — no tsconfig needed, no compilation, just run `.ts` files directly. It does not type-check and does not support some TypeScript features (decorators, const enum). Use tsx in dev, compile to JS for production.
Fastify achieves ~3× the throughput of Express primarily through: (1) **Schema-based serialization** — JSON schemas declared per route allow Fastify to compile optimised serializers using `fast-json-stringify`, avoiding `JSON.stringify` reflection. (2) **Schema-based validation** with Ajv compiled into functions, not evaluated per-request. (3) A **radix-tree router** (`find-my-way`) faster than Express's sequential stack. (4) Minimal overhead in the request lifecycle. Fastify also provides a plugin system with encapsulation (each plugin gets its own DI scope), lifecycle hooks, TypeScript support built in, and `@fastify/swagger` for OpenAPI docs generation.
NestJS is an opinionated framework built on Express or Fastify that brings Angular-style architecture to Node.js. **Modules** encapsulate a slice of the app — they declare which providers and controllers they contain and what they export. **Providers** are injectable classes (services, repositories, guards) decorated with `@Injectable()`. **Dependency Injection**: NestJS maintains an IoC container; when a controller or service declares a constructor parameter, the container resolves and injects the appropriate provider instance. This enables testability (inject mocks), scoping (singleton vs request-scoped), and clean separation of concerns. **Controllers** handle HTTP routes; **services** hold business logic.
**Jest**: the most popular choice; rich assertion library, snapshot testing, mocking, code coverage. Works out of the box with CJS; for ESM requires `--experimental-vm-modules` or `babel-jest`. **Vitest**: built on Vite, natively supports ESM and TypeScript without transpilation, compatible Jest API, faster for frontend-adjacent Node.js code. **`node:test`** (Node.js 18+): built-in test runner, no install needed. Supports `test()`, `describe()`, `before`/`after` hooks, subtests, mock functions, and TAP output. Good for utilities and libraries where you want zero dev dependencies. ```js import { test } from 'node:test'; import assert from 'node:assert'; test('adds', () => assert.equal(1 + 1, 2)); ```
Start the process with `node --inspect src/index.js` (or `--inspect-brk` to pause before the first line). Chrome: navigate to `chrome://inspect`, click "Open dedicated DevTools for Node", and you get breakpoints, watch expressions, call stacks, heap snapshots, and CPU profiles. VS Code: create `.vscode/launch.json` with type `"node"` and program path; press F5. Use `"restart": true` and `"runtimeExecutable": "nodemon"` to auto-reattach on file changes. For already-running processes send `SIGUSR1` (`kill -usr1 <pid>`) to enable the inspector dynamically.
`node --prof` generates a V8 isolate log (`isolate-*.log`) as the process runs. Process it with `node --prof-process isolate-*.log > processed.txt`. The output shows ticks (samples) by function, broken into JavaScript, native, GC, and no-info categories. High-tick functions are hot paths. For a flamegraph, use `0x` (`npx 0x -- node src/index.js`) which captures perf events and renders an interactive SVG flamegraph in the browser — much easier to spot hot call chains than reading raw tick data.
`clinic.js` is a suite of performance profiling tools by NearForm. **clinic doctor** runs your app, monitors event loop delay, CPU, memory, and libuv handles, then generates an HTML report diagnosing common issues: event loop blocking, memory leaks, libuv thread pool saturation, GC pressure. **clinic flame** captures CPU profiles and renders an interactive flamegraph showing which functions dominate CPU time. **clinic bubbleprof** visualises async operations and their durations. Usage: `clinic doctor -- node server.js` then apply load with `autocannon`. The tool automatically opens the report in your browser.
Install `@opentelemetry/sdk-node`, `@opentelemetry/auto-instrumentations-node`, and an exporter (e.g., `@opentelemetry/exporter-otlp-http`). Create a `tracing.js` that initialises the SDK before any other imports and start it with `-r ./tracing.js`. Auto-instrumentations patch `http`, `express`, `pg`, `redis`, `fs`, etc. automatically. ```js import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; const sdk = new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()] }); sdk.start(); ``` Spans are exported to your backend (Jaeger, Honeycomb, Datadog). Manually create spans with the `@opentelemetry/api` tracer for custom operations.
PM2 cluster mode forks your app across all CPU cores. `pm2 reload app` performs a rolling restart: it brings up one new worker, waits for it to send `process.send('ready')`, then kills one old worker — repeating until all are replaced. No connections drop because there is always at least one worker serving traffic. Signal readiness with `await server.listen(port); process.send('ready')`. Configure `wait_ready: true` and `listen_timeout` in `ecosystem.config.js`. In Kubernetes, use `SIGTERM`-based graceful shutdown instead and rely on rolling deployments rather than PM2.
Key best practices: (1) **Multi-stage build** — install deps in a builder stage, copy only `node_modules` and compiled output to the runtime stage, keeping the image lean. (2) **Non-root user** — `USER node` or `RUN adduser --disabled-password app; USER app` to avoid container root escalation. (3) **Layer caching** — copy `package.json`/`package-lock.json` and run `npm ci` before copying source so the dep layer is cached unless lockfile changes. (4) **`.dockerignore`** — exclude `node_modules`, `.git`, `.env`, test files. (5) Pin a specific Node.js version: `FROM node:20.14-alpine3.19`. (6) Use `npm ci --omit=dev` for production installs.
Node.js follows a predictable release cycle: even-numbered major versions (18, 20, 22) enter **Active LTS** six months after their initial release and receive bug fixes, security patches, and performance improvements for 18 months. They then move to **Maintenance** for 12 more months — critical security and bug fixes only. Odd-numbered versions (19, 21) are non-LTS: they receive updates for 6 months then are cut off entirely. The release schedule is published at nodejs.org/en/about/releases. For production, always use an Active LTS version. Currently Node.js 22 is Active LTS; Node.js 20 is Maintenance; Node.js 18 EOL was April 2025.
ESM loaders let you intercept module resolution and loading. With `--import ./register.js` (Node.js 20.6+) you register hooks via `module.register()` from within the process without a subprocess. A loader module exports `resolve` and `load` hooks. `resolve` receives the specifier and can rewrite it; `load` receives the resolved URL and can transform the source (e.g., strip TypeScript types). Example for path aliases: ```js // register.js import { register } from 'node:module'; register('./aliases-loader.js', import.meta.url); // aliases-loader.js export function resolve(specifier, ctx, nextResolve) { if (specifier.startsWith('@/')) specifier = specifier.replace('@/', new URL('../src/', import.meta.url).href); return nextResolve(specifier, ctx); } ``` Loaders run in a separate thread from the main module to avoid deadlocks. This replaces the older `--experimental-loader` flag which ran loaders in-process.
Extend `Readable` in object mode, open the file with `fs.open`, and use `fs.read` to pull chunks into a buffer, splitting on newlines. Or, more idiomatically, compose built-in streams: ```js import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { Readable } from 'node:stream'; export function lineStream(filePath) { const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity, }); return Readable.from(rl); // async iterable → Readable } for await (const line of lineStream('huge.log')) { process(line); // only one chunk in memory at a time } ``` `Readable.from()` wraps any async iterable into a proper Readable, respecting backpressure automatically. Memory usage is bounded by the internal highWaterMark (16 lines by default in object mode) rather than the file size.
V8 tracks the "shape" (also called hidden class or map) of every object — the ordered set of property names and their internal slot indices. When multiple objects share the same shape, V8 generates optimised machine code with inline property access via fixed offsets, as fast as a C struct field access. Adding properties in a consistent order maintains the same shape chain. If you add properties in different orders across instances, or conditionally add properties, each object gets a different shape and V8 can no longer optimise the access — it falls back to a dictionary lookup (deopt). Always initialise all properties in the constructor, in the same order, with the same types. Changing a property from integer to floating-point also creates a new shape.
V8 compiles JS through several tiers: the parser, Sparkplug (baseline, no type feedback), and TurboFan (optimising compiler). TurboFan collects type feedback from Ignition bytecode to produce highly specialised machine code — e.g., treating a variable as always being a small integer. If that assumption is violated at runtime (a non-integer is passed), V8 "deoptimises": it discards the TurboFan code, falls back to interpreted bytecode, and re-profiles. Triggers: changing object shape after compilation, passing different argument types, `arguments` object access in optimised frames, `try/catch` around hot code, `eval`, and calling functions with the wrong arity. Repeated deopts ("deopt loops") are detected by `--trace-deopt` and `node --prof`.
libuv abstracts OS-specific async I/O APIs. On **Linux**, it uses `epoll`: a file descriptor is added to an epoll instance with `epoll_ctl(EPOLL_CTL_ADD, fd, EPOLLIN|EPOLLOUT)`; the poll phase calls `epoll_wait()` to block until any watched FD is ready, then fires the corresponding callback. On **macOS/BSD**, the equivalent is `kqueue` with `kevent`. On **Windows**, libuv uses IOCP (I/O Completion Ports): the OS completes the I/O and posts a completion packet to the port; a single `GetQueuedCompletionStatusEx` call dequeues all ready operations. For operations without native async support (most `fs` calls on all platforms), libuv falls back to the thread pool — a worker thread performs the blocking syscall, then posts a completion event back to the event loop.
Concurrency in Node.js network I/O is entirely OS-driven. When you call `http.get()` or connect a socket, Node.js registers the file descriptor with the OS poller (epoll/kqueue/IOCP) via libuv, then returns immediately. The event loop blocks in `epoll_wait` during the poll phase. The OS kernel receives incoming packets, buffers them in the socket's receive buffer, and marks the FD as readable without any Node.js thread involvement. When `epoll_wait` returns, libuv sees which FDs are ready, calls the JS callbacks for each, and those callbacks run sequentially on the single JS thread. Thousands of open sockets cost only kernel buffer memory and an FD entry — no thread per connection. This is the C10K problem solution.
`SharedArrayBuffer` (SAB) is a fixed-length raw binary memory region that is shared — not copied — between threads. You create it in the main thread, pass it to a worker via `workerData` or `postMessage` with the transfer list, and both sides create a TypedArray view over it (e.g., `new Int32Array(sab)`). Concurrent reads/writes without coordination produce data races. `Atomics` provides lock-free atomic operations: `Atomics.add`, `Atomics.compareExchange`, `Atomics.store`, and `Atomics.load`. `Atomics.wait(ia, index, value)` blocks the calling thread until the value changes; `Atomics.notify(ia, index, count)` wakes blocked threads. ```js const sab = new SharedArrayBuffer(4); const ia = new Int32Array(sab); // Worker: Atomics.add(ia, 0, 1) — thread-safe increment ```
A minimal pool tracks idle connections, pending acquire callbacks, and total size. `acquire()` returns an idle connection or queues a callback if at capacity. `release(conn)` checks if anyone is waiting, otherwise returns to the idle list. Eviction runs on a timer checking `lastUsed` timestamps. ```js class Pool { constructor({ create, destroy, max = 10, idleTimeout = 30000 }) { this.create = create; this.destroy = destroy; this.max = max; this.idle = []; this.pending = []; this.size = 0; } async acquire() { if (this.idle.length) return this.idle.pop(); if (this.size < this.max) { this.size++; return this.create(); } return new Promise(resolve => this.pending.push(resolve)); } release(conn) { if (this.pending.length) return this.pending.shift()(conn); conn.lastUsed = Date.now(); this.idle.push(conn); } } ``` Production pools also handle connection validation (ping on acquire), error on broken connections, circuit breaking, and metrics on pool utilisation.
`async_hooks` fires four callbacks for every async resource. **`init(asyncId, type, triggerAsyncId)`**: a new async resource is created; `triggerAsyncId` is the parent context ID — this builds the parent-child chain for trace propagation. **`before(asyncId)`**: the callback for this resource is about to run — activate its span. **`after(asyncId)`**: the callback finished — deactivate the span. **`destroy(asyncId)`**: the resource has been GC'd — clean up any stored context. OpenTelemetry's Node.js SDK uses `AsyncLocalStorage` (which builds on `async_hooks`) to propagate `TraceContext` (traceId + spanId) through every `await`, callback, and timer without manual context passing — every log line and outbound HTTP call automatically carries the correct trace IDs.
`AsyncResource` lets you manually associate a piece of code with an async context that already exists. When libuv thread pool callbacks execute (e.g., a `fs.readFile` completion or a custom `Napi::AsyncWorker` done callback), they run outside any async context — `AsyncLocalStorage.getStore()` returns `undefined`. By wrapping the callback with `asyncResource.runInAsyncScope(callback)`, you re-enter the original context. ```js import { AsyncResource } from 'node:async_hooks'; class TracedPool { run(fn) { const res = new AsyncResource('TracedPool'); threadPool.submit(() => res.runInAsyncScope(fn)); } } ``` Node.js's `events.EventEmitterAsyncResource` extends `EventEmitter` with this behaviour built in.
V8 startup normally involves parsing and compiling the JavaScript for the runtime context, initialising built-in objects, and binding C++ functions. Snapshots serialise the entire V8 heap state after this initialisation into a blob. On subsequent starts, V8 deserialises the heap from the blob — mapping it into memory — instead of re-executing all the setup JavaScript, saving 20–80ms per startup. Node.js ships with a built-in startup snapshot for the core runtime. Since Node.js 18, you can create custom application snapshots that also serialise your app's module graph: `node --build-snapshot entry.js` produces a blob that `node --snapshot-blob snapshot.blob entry.js` uses. Useful for CLIs and serverless functions where cold-start time matters.
The key is to treat the upload as a stream and pipe it directly to its destination without accumulating it in memory. `busboy` is the canonical multipart parser: it parses the `multipart/form-data` stream and emits a readable stream per file part, which you can pipe directly to S3 (via AWS SDK's `upload` stream API), a write stream, or a Transform. ```js import Busboy from 'busboy'; app.post('/upload', (req, res) => { const bb = Busboy({ headers: req.headers }); bb.on('file', (name, stream, info) => { stream.pipe(s3UploadStream(info.filename)); }); req.pipe(bb); bb.on('finish', () => res.sendStatus(200)); }); ``` Memory usage stays bounded to a few chunks regardless of file size. `multer` wraps busboy for Express. Always set `limits.fileSize` to reject oversized uploads early.
Model the producer as a `Readable` and the consumer as a `Writable`. The `Readable`'s `_read` is called when the consumer is ready for more — this is the pull signal. Backpressure is automatic: when the Writable's buffer is full, `write()` returns `false`, pausing the pipe. ```js import { Readable, Writable } from 'node:stream'; const producer = new Readable({ objectMode: true, read() { this.push(generateWork()); /* push null when done */ }, }); const consumer = new Writable({ objectMode: true, async write(item, _, done) { await processItem(item); done(); // signal ready for next }, highWaterMark: 4, // queue at most 4 items }); await pipeline(producer, consumer); ``` The `highWaterMark` on the Writable controls queue depth. When 4 items are buffered, the Readable pauses automatically.
V8's minor GC (scavenger) is incremental and runs in small slices between tasks — it can fire between any two JavaScript tasks (event loop iterations). Major GC (mark-sweep-compact) is more disruptive: it pauses the event loop for the duration of the stop-the-world phase, which can range from a few milliseconds to hundreds for multi-gigabyte heaps. V8 schedules idle-time GC when the event loop's poll phase has no ready I/O — it uses idle notifications from libuv. You can monitor GC with `--trace-gc` or the `performance` hooks. The practical implication: a large heap means GC pauses that delay I/O callbacks, causing latency spikes. Keep heap usage low and avoid long-lived object graphs.
The `nextTick` queue is drained **completely** before the event loop advances to the next phase. If a `nextTick` callback schedules another `nextTick`, the loop never exits to poll for I/O. Example: ```js function recursiveTick() { process.nextTick(recursiveTick); } recursiveTick(); // starves all I/O, timers, and setImmediate forever fs.readFile('file.txt', cb); // cb NEVER fires ``` Compare with `setImmediate(recursiveImmediate)`: the check phase runs once per loop iteration, so I/O can still be processed between `setImmediate` calls. The fix is to use `setImmediate` for recursive async work, or process a batch of items per tick. `process.nextTick` is appropriate for short, non-recursive deferred callbacks only.
SEA lets you bundle a Node.js application into a single portable binary that includes the Node.js runtime, eliminating the need for a Node.js installation on the target machine. The process: (1) Create a `sea-config.json` specifying the entry script and output blob path. (2) Run `node --experimental-sea-config sea-config.json` to generate a prepared blob. (3) Copy the system `node` binary and inject the blob using `postject`. The binary signature may need re-signing on macOS. The resulting binary is self-contained but large (~90MB). SEA uses the V8 snapshot mechanism to embed the application. It is still experimental in Node 20 but reached stability in Node 22. Useful for distributing CLI tools without packaging a runtime separately.
Use N-API when you need (1) raw CPU performance impossible to achieve in JS, (2) bindings to a C/C++ library (e.g., `libvips` for sharp), (3) access to hardware or OS APIs, or (4) deterministic memory layouts. With `node-addon-api` (C++ wrapper): define an `Napi::Object Init` export, use `Napi::Function::New` to expose functions, and `Napi::AsyncWorker` for thread-pool work to avoid blocking the event loop. ```cpp Napi::Value Add(const Napi::CallbackInfo& info) { double a = info[0].As<Napi::Number>().DoubleValue(); double b = info[1].As<Napi::Number>().DoubleValue(); return Napi::Number::New(info.Env(), a + b); } ``` Build with `node-gyp`. N-API's stable ABI means the `.node` file works across Node.js versions without recompilation, unlike the old `nan`-based approach.
Use a `Map` keyed by event name to a `Set` of strongly-typed listeners, leveraging TypeScript generics to enforce handler signatures at compile time. ```ts type EventMap = Record<string, unknown>; class EventBus<T extends EventMap> { private listeners = new Map<keyof T, Set<(payload: T[keyof T]) => void>>(); on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void { if (!this.listeners.has(event)) this.listeners.set(event, new Set()); (this.listeners.get(event) as Set<(p: T[K]) => void>).add(handler); return () => this.off(event, handler); } off<K extends keyof T>(event: K, handler: (payload: T[K]) => void) { this.listeners.get(event)?.delete(handler as never); } emit<K extends keyof T>(event: K, payload: T[K]) { this.listeners.get(event)?.forEach(h => (h as (p: T[K]) => void)(payload)); } } ``` Usage: `const bus = new EventBus<{ userCreated: { id: string } }>()`. The `on` return value is an unsubscribe function, preventing listener leaks.
In Node.js, `require.cache` is a flat registry — once a module is loaded it stays until manually evicted with `delete require.cache[path]`. There is no built-in HMR. Tools like `nodemon` simply restart the entire process. Custom HMR (used by some dev servers) invalidates a module's cache entry and all modules that transitively required it — traversable by scanning `module.children` or the cache graph. The challenge is that singletons (DB connections, state) should NOT be re-initialised — HMR must identify which modules are safe to hot-swap. In ESM, there is no `module.cache` API at all. Vite's HMR for Node.js (via Vite Node) uses dynamic imports with version-busted query strings to force re-evaluation of changed modules.
When you call `worker.postMessage(data)`, Node.js deep-clones `data` using the structured clone algorithm (SCA). SCA handles primitives, plain objects, arrays, `Map`, `Set`, `Date`, `RegExp`, `Error`, `ArrayBuffer`/TypedArrays, `Blob`, and circular references (unlike `JSON.stringify`). It does NOT support: functions, class instances (prototype chain is lost), DOM nodes, or `WeakMap`/`WeakSet`. For large binary data, SCA by default copies — use the transfer list to zero-copy transfer ownership: `worker.postMessage(buffer, [buffer])`. After transfer, the original `ArrayBuffer` is neutered (zero-length). `SharedArrayBuffer` is never transferred, it is shared by reference.
WebSocket connections are long-lived — a rolling restart that closes existing sockets disrupts users. Strategy: (1) **Blue/green at the load balancer level** — spin up the green version behind the LB, health-check it, then cut all new connections to green while blue drains. (2) **Drain with a timeout** — on `SIGTERM`, stop accepting new WS upgrades, broadcast a `{"type":"reconnect"}` message to all connected clients telling them to reconnect in 5–30s (with jitter), close connections gracefully as clients disconnect, then exit after a drain timeout. (3) **Stateless design** — store session state in Redis (not in-process), so reconnected clients restore state from Redis regardless of which server they land on. (4) Use a sticky-session LB or a distributed pub/sub (Redis Pub/Sub, Ably) to route messages to the correct server after reconnection.
Pipe the file through a line-splitting Transform, parse each line as JSON: ```js import { createReadStream } from 'node:fs'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; function ndjsonParser() { let buf = ''; return new Transform({ objectMode: true, transform(chunk, _, done) { buf += chunk.toString(); const lines = buf.split('\n'); buf = lines.pop(); // save incomplete trailing line for (const line of lines) { if (line.trim()) this.push(JSON.parse(line)); } done(); }, flush(done) { if (buf.trim()) this.push(JSON.parse(buf)); done(); }, }); } await pipeline( createReadStream('logs.ndjson'), ndjsonParser(), new Writable({ objectMode: true, write(obj, _, cb) { process(obj); cb(); } }) ); ``` Memory is bounded to one chunk at a time plus the incomplete line buffer. `flush` handles the last line if the file doesn't end with a newline.
`process.stdin` is an instance of `tty.ReadStream` when attached to a terminal and a plain `Readable` when piped. Check `process.stdin.isTTY` to detect the context. In TTY mode, the terminal is in "cooked" (canonical) mode by default — input is line-buffered and sent only on Enter. For interactive CLIs (like `vim`, `less`, prompts) you switch to "raw" mode with `process.stdin.setRawMode(true)` — each keypress is delivered immediately without Enter, and control characters (`Ctrl-C`) are not automatically handled. Libraries like `inquirer` and `readline` manage raw mode for you. In piped mode, `isTTY` is `undefined`/`false`, no terminal features are available, and you read line-by-line with `createInterface`.
`vm.runInNewContext` is **not** a security sandbox. The sandbox object becomes the global, but the untrusted script can escape via prototype chains: `({}).constructor.constructor('return process')()` retrieves the main `process` object, granting full system access. Passing any native object (e.g., a Buffer) also exposes its prototype chain. Countermeasures: use `Object.create(null)` as the sandbox and avoid passing any native references — but even this can be bypassed by a determined attacker. **Real sandboxing options**: (1) **Worker Threads** with no `workerData` native objects — serialised data only; (2) **`child_process` with `--disallow-code-generation-from-strings`** and a restricted environment; (3) **Deno** (designed with sandboxing in mind); (4) **V8 Isolates** via Cloudflare Workers runtime (`workerd`). For user-provided code execution use an isolated process with `--experimental-permission` or a dedicated VM service.
When you call `require('X')` from file `Y`: (1) If `X` is a core module (`fs`, `path`, etc.), return it immediately. (2) If `X` starts with `./`, `/`, `../` — **LOAD_AS_FILE**: try `X`, `X.js`, `X.json`, `X.node`. If none found — **LOAD_AS_DIRECTORY**: try `X/package.json` (`main` field), then `X/index.js`, `X/index.json`, `X/index.node`. (3) Otherwise — **LOAD_NODE_MODULES**: walk up the directory tree from `Y`, checking `node_modules/X` at each level using LOAD_AS_FILE and LOAD_AS_DIRECTORY. This means a module in a subdirectory can shadow a parent's version. The resolution order for ESM adds `exports` field in `package.json` (checked first) and `imports` for internal package aliases. `require.resolve(X)` returns the resolved path without loading.
Node.js 20 introduced an experimental permission model that restricts what a Node.js process can do at the OS level — inspired by Deno's security model. It is opt-in: run with `--experimental-permission` and then grant specific permissions explicitly. `--allow-fs-read=/tmp` allows reading from `/tmp`; `--allow-fs-write` allows writes; `--allow-net` allows network access; `--allow-child-process` allows spawning subprocesses. Without a permission flag, the corresponding action throws `ERR_ACCESS_DENIED`. Check at runtime with `process.permission.has('fs.read', '/etc/passwd')`. In Node.js 22 the API graduated to `--permission` (dropping "experimental"). This is a defence-in-depth layer — not a replacement for OS-level sandboxing but useful for running untrusted plugins or limiting blast radius of a compromised module.
Use the `heapdump` package or the built-in `v8.writeHeapSnapshot()` to capture snapshots. Workflow: (1) Send `SIGUSR2` to trigger a snapshot (`heapdump` listens for this by default). (2) Apply load for N minutes. (3) Force GC with `global.gc()` (requires `--expose-gc`). (4) Capture a second snapshot. (5) Open Chrome DevTools → Memory → Load both snapshots. Use the **Comparison** view to see which object types grew between snapshots — object `#New` count and `#Freed` count. Click into a constructor name to see all live instances and their retainer chains (what is holding them in memory). Common leaks: EventEmitter listeners accumulating in an Array, Map/Set growing unbounded, closures in setInterval callbacks capturing large objects.
The "banana split problem" (also called the "destroy on error" problem) occurs with manual `.pipe()` chains: if an intermediate stream in the chain emits an error, `.pipe()` unpipes from the destination but does **not** destroy the upstream source or other streams — they remain open indefinitely, leaking file handles, sockets, and memory. Example: `source.pipe(transform).pipe(destination)` — if `transform` errors, `source` keeps reading data to nowhere. `stream.pipeline(source, transform, destination, callback)` (or its Promise variant) registers error listeners on every stream and calls `stream.destroy()` on all of them the moment any one errors, then calls the callback with the error. This makes cleanup reliable and automatic, which is why all production streaming code should use `pipeline`.
HTTP/2 uses a single TCP connection over which multiple **streams** are multiplexed. Each stream is an independent request/response exchange with its own stream ID; frames from different streams are interleaved on the wire. This eliminates HTTP/1.1's head-of-line blocking — a slow response on one stream doesn't block others. In Node.js, use `http2.createSecureServer()` or `http2.createServer()`. Server push (`stream.pushStream()`) lets the server proactively send assets. HTTP/2 is most beneficial when: many small parallel requests are made (e.g., loading many API resources), latency to the server is high (multiplexing amortises RTT), and with TLS (combined TLS+H2 handshake is faster than separate). For single large payloads, HTTP/1.1 keep-alive is comparable. HTTP/2 over a lossy connection can perform worse than multiple HTTP/1.1 connections because TCP packet loss stalls all streams.
Redis Streams (`XADD`/`XREADGROUP`/`XACK`) provide durable, at-least-once delivery with consumer groups — superior to `LPUSH/BRPOP` (no acknowledgement) and Pub/Sub (fire-and-forget). ```js // Producer await redis.xadd('jobs', '*', 'type', 'email', 'payload', JSON.stringify(data)); // Consumer while (true) { const [[, entries]] = await redis.xreadgroup( 'GROUP', 'workers', 'worker-1', 'COUNT', 10, 'BLOCK', 2000, 'STREAMS', 'jobs', '>' ); for (const [id, fields] of entries) { await processJob(Object.fromEntries(fields)); await redis.xack('jobs', 'workers', id); } } ``` Unacknowledged messages stay in the PEL (Pending Entries List). A reaper process uses `XAUTOCLAIM` to reclaim jobs idle > N seconds and re-enqueue them. BullMQ abstracts this entire pattern over Redis Streams.
The pattern (also called a "dataloader" or "in-flight deduplication") stores in-progress Promises keyed by query fingerprint. If a second identical request arrives before the first resolves, it receives the same Promise instead of triggering a new DB query. ```ts class CoalescingCache<K, V> { private inflight = new Map<K, Promise<V>>(); private cache = new Map<K, V>(); async get(key: K, fetcher: () => Promise<V>, ttlMs = 5000): Promise<V> { if (this.cache.has(key)) return this.cache.get(key)!; if (this.inflight.has(key)) return this.inflight.get(key)!; const p = fetcher().then(val => { this.cache.set(key, val); this.inflight.delete(key); setTimeout(() => this.cache.delete(key), ttlMs); return val; }).catch(err => { this.inflight.delete(key); throw err; }); this.inflight.set(key, p); return p; } } ``` This reduces DB load dramatically under concurrent traffic for the same resource — a cold-start thundering herd that would fire 200 identical queries fires only one. `DataLoader` from Facebook generalises this with batching.

Frequently Asked Questions

Which Node version to target?

Node 20 LTS or 22. Know ESM interop, --watch, fetch built-in, and node:test.

Which framework?

Express is still most common in interviews; Fastify and NestJS are growing. Know what each solves.

How is Node different from browser JS?

Same language, different runtime: Node has filesystem, network, and child_process APIs, plus its own module system. The event loop has additional phases.

What about TypeScript on Node?

Very common today. Know tsx/ts-node/Node native type stripping (--experimental-strip-types).

How do you scale Node?

Stateless services behind a load balancer, cluster/PM2 to use all cores, or move CPU-heavy work to workers or a different language.

Related Topics

Ready to apply?

TryApplyNow scores matches, tailors resumes, and tracks applications so you can focus on prep, not paperwork.

Try for free →