The Node.js Event Loop Phases, Explained
What libuv does on each turn of the loop, where nextTick, promises, setImmediate and timers really run, and how to keep CPU-heavy work from blocking everything.
JavaScript on the server: event loop phases, streams, modules, workers, error handling and performance.
What libuv does on each turn of the loop, where nextTick, promises, setImmediate and timers really run, and how to keep CPU-heavy work from blocking everything.
Node.js is a JavaScript runtime built on Google's V8 engine plus libuv, a C library that provides the event loop, asynchronous I/O and a small thread pool. It lets you run JavaScript outside the browser, with APIs for files, networking, processes and more.
Your JavaScript runs on one main thread, but I/O doesn't block it. When you read from a socket or a file, Node hands the work to the operating system (epoll, kqueue, IOCP) or to libuv's thread pool and moves on. When the result is ready, a callback is queued and the event loop runs it.
So a connection that's waiting on a database costs almost nothing: no thread is parked on it. That's why Node shines at I/O-bound work like APIs, proxies and real-time apps. The trade-off is that CPU-heavy code blocks every request, so it belongs in worker threads or a separate service.
Likely follow-up: What happens if you block the event loop? · Is Node.js really single-threaded?
After the main script runs, libuv's loop repeats a fixed cycle of phases, each with its own callback queue:
setTimeout and setInterval callbackssetImmediate callbackssocket.on('close') after a destroy()When there's nothing to do, the loop waits in poll for I/O, but only until the nearest timer is due, and it doesn't wait at all if immediates are queued. process.nextTick and promise callbacks aren't phases: Node drains those queues after every individual callback. When no active handles or requests remain, the loop exits and the process ends.
setImmediate runs in the check phaseLikely follow-up: Why does setImmediate always run before setTimeout(fn, 0) inside an I/O callback?
process.nextTick, promise callbacks, setImmediate and setTimeout(fn, 0) differ? What does this CommonJS script print?midThey go to different queues:
process.nextTick callbacks run as soon as the current operation finishes, before anything else, including promises.queueMicrotask) are microtasks, drained right after the nextTick queue.setTimeout(fn, 0) goes to the timers phase; a delay of 0 is really 1 ms.setImmediate goes to the check phase, right after poll.So it prints sync, nextTick, promise, then timeout and immediate in either order: from the main module it depends on whether 1 ms has passed when the loop starts. Inside an I/O callback, setImmediate always wins, because check comes straight after poll.
Gotcha: saved as an ES module, it prints promise before nextTick. ES modules are evaluated asynchronously, from inside a microtask, so V8 drains pending promise callbacks before Node gets back to the nextTick queue.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');setTimeout(fn, 0) is the timers phase, at least 1 mssetImmediate runs in the check phase, after pollLikely follow-up: What happens if you call process.nextTick recursively?
It prints script start, a start, b, script end, tick, a end, then, timeout.
a() runs its body synchronously up to the first await, and b() runs synchronously too, so a start and b print before script end.await b() suspends a. Because b() returns a native promise that's already resolved, the rest of a is queued as a microtask right away, before the .then callback registered on the next line.process.nextTick queue first (tick), then the promise microtasks in FIFO order (a end, then).timeout).Run the same code as an ES module and tick moves after then: module evaluation is itself asynchronous, so promise microtasks drain before Node processes the nextTick queue.
async function a() {
console.log('a start');
await b();
console.log('a end');
}
async function b() {
console.log('b');
}
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
a();
Promise.resolve().then(() => console.log('then'));
process.nextTick(() => console.log('tick'));
console.log('script end');awaitawait resumes as a microtaskCommonJS uses require() and module.exports. Loading is synchronous, require can be called anywhere (even conditionally), and each module gets __dirname and __filename.
ES modules use import and export. Static imports are hoisted and analyzable, which enables tree-shaking; loading is asynchronous, top-level await works, code is always in strict mode, and exports are live bindings. Relative imports need the full file extension, like ./utils.js.
Node picks the format per file:
.mjs is always ESM and .cjs is always CommonJS..js follows the nearest package.json: "type": "module" means ESM, otherwise CommonJS..js file with no type and reparses it as ESM, with a warning, but you should set type explicitly.For new code, ESM is the standard.
require is synchronous and dynamic; import is static.mjs and .cjs force the format.js follows the nearest package.json type field__dirnameLikely follow-up: Can a CommonJS file require() an ES module?
Blocking means one piece of synchronous JavaScript holds the main thread so long that the loop can't run anything else: no other requests, no timers, no I/O callbacks. In the snippet, a single request to /slow freezes the whole server for five seconds.
Common causes:
JSON.parse or JSON.stringify on very large payloads*Sync APIs like fs.readFileSync or crypto.pbkdf2Sync in the request pathFixes: move CPU work to worker_threads or a job queue, use async and streaming APIs, split long loops into chunks with setImmediate so I/O can interleave, and cap input sizes. To detect it, track event loop delay with perf_hooks.monitorEventLoopDelay() or a profiler.
import http from 'node:http';
http.createServer((req, res) => {
if (req.url === '/slow') {
const end = Date.now() + 5000;
while (Date.now() < end) {} // every other request now waits 5 s
}
res.end('ok');
}).listen(3000);*Sync APIs, ReDoSsetImmediateLikely follow-up: How would you process a 1 GB JSON file without blocking?
module.exports and exports in CommonJS?easyNode wraps every CommonJS file in a function, roughly (function (exports, require, module, __filename, __dirname) { ... }). exports is just a local parameter that starts out pointing at the same object as module.exports.
require() always returns module.exports. Adding properties to exports works because you're mutating that shared object. But reassigning exports only rebinds the local variable: the link is broken and callers get whatever module.exports still holds, which in b.js is an empty object.
Rule of thumb: use exports.name = ... for several named exports, and module.exports = ... when you export a single function, class or object. Don't mix the two styles in one file.
// a.js: works, mutates the shared object
exports.add = (a, b) => a + b;
// b.js: broken, require('./b') returns {}
exports = { add: (a, b) => a + b };
// c.js: works
module.exports = { add: (a, b) => a + b };exports starts as a reference to module.exportsrequire() returns module.exportsexports breaks the linkLikely follow-up: Where do require, __dirname and module come from in a CommonJS file?
A stream processes data piece by piece, in chunks, instead of loading it all into memory. That keeps memory flat for large files or responses and lets you start sending output before the input is finished.
The four types:
fs.createReadStream or an incoming HTTP requestfs.createWriteStream or an HTTP responsezlib.createGzip() or a CSV parserStreams are EventEmitters: readables emit data, end and error; writables emit drain and finish. They carry Buffers or strings by default, or any JavaScript value in objectMode. You connect them with pipe() or, better, stream.pipeline(), which also handles errors and cleanup.
pipeline() for errors and backpressureLikely follow-up: What is backpressure and how do streams handle it?
uncaughtException and unhandledRejection for?midIt depends on the API style:
(err, result) => { if (err) return handle(err); ... }. A try/catch around the call won't help, because the callback runs later.await inside try/catch, or attach .catch(). Every chain needs a handler somewhere.'error' event; if nobody listens, it's thrown and crashes the process.process.on('uncaughtException') fires for a thrown error nothing caught, and process.on('unhandledRejection') for a rejected promise with no handler. Since Node 15, an unhandled rejection crashes the process by default, just like an uncaught exception.
Use those process handlers only as a last resort to log and exit, because the app may be in an unknown state; let a process manager restart it. Also separate operational errors (timeouts, bad input), which you handle, from programmer errors (bugs), which you fix.
err firsttry/catch around await, or .catch()'error' events crash the processLikely follow-up: Why is it unsafe to keep running after an uncaughtException?
Middleware is a function (req, res, next) in the request pipeline. Express runs middleware in the order it was registered; each one can read or modify req and res, end the response, or call next() to pass control on. If it does neither, the request hangs. Typical uses are body parsing, logging, auth, CORS and rate limiting.
Error-handling middleware takes four arguments, (err, req, res, next), and Express recognizes it by that arity. When a handler throws or calls next(err), Express skips the remaining regular middleware and jumps to the next error handler, so register error handlers last, after your routes.
In Express 4 you had to catch async errors yourself and call next(err). Express 5 automatically forwards rejected promises from async handlers to the error middleware.
const app = express();
app.use(express.json()); // built-in body parser
app.use((req, res, next) => {
console.log(req.method, req.url);
next(); // pass control to the next middleware
});
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // Express 5 forwards rejections
res.json(user);
});
app.use((err, req, res, next) => {
// error handler: recognized by its four arguments
res.status(err.status ?? 500).json({ error: err.message });
});(req, res, next), runs in registration ordernext() or end the responsenext(err) skips straight to error handlersLikely follow-up: How would you write an authentication middleware?
Your JavaScript runs on one thread, but the process isn't single-threaded. V8 uses helper threads for garbage collection and compilation, and libuv keeps a thread pool for work the OS can't do asynchronously.
The pool has 4 threads by default, configurable with the UV_THREADPOOL_SIZE environment variable (maximum 1024) when the process starts. It's used by:
fs operationsdns.lookup(), which http.get and net.connect use to resolve hostnamescrypto such as pbkdf2, scrypt, randomBytes and generateKeyPairzlib compressionNetwork I/O on TCP and HTTP sockets does not use the pool; it relies on OS mechanisms like epoll and kqueue. In the snippet, hashes five and six wait for a free thread. The same queuing happens when many slow file or DNS calls pile up, which shows up as mysterious latency.
import { pbkdf2 } from 'node:crypto';
const start = Date.now();
for (let i = 1; i <= 6; i++) {
pbkdf2('pw', 'salt', 200_000, 64, 'sha512', () => {
console.log(i, Date.now() - start, 'ms');
});
}
// Default pool: four finish together, the last two take about twice as long.
// With UV_THREADPOOL_SIZE=6, all six finish together.UV_THREADPOOL_SIZE at startupdns.lookup, async crypto, zlibLikely follow-up: Why might raising UV_THREADPOOL_SIZE not make things faster?
worker_threads, child_process or cluster?midAll three add parallelism, at different levels:
postMessage (structured clone), can transfer ArrayBuffers without copying, and can share memory through SharedArrayBuffer. Use them for CPU-heavy JavaScript: parsing, image resizing, hashing.spawn streams output, exec runs a shell command and buffers the output, execFile skips the shell, and fork starts another Node script with an IPC channel. Use it for external tools like ffmpeg or git, or for full isolation.In short: workers for CPU tasks, child processes for other programs, cluster for scaling a server.
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
if (isMainThread) {
const worker = new Worker(new URL(import.meta.url), { workerData: 35 });
worker.on('message', (n) => console.log('fib =', n)); // fib = 9227465
worker.on('error', console.error);
} else {
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
parentPort.postMessage(fib(workerData)); // runs off the main thread
}spawn, exec, execFile, fork differ in shell and outputLikely follow-up: Why not create a new worker for every request?
npm is the package manager. It installs, updates and removes packages, maintains package.json and the lockfile, runs scripts with npm run <name>, and publishes packages to the registry.
npx runs a package's executable. If the binary is in the project's node_modules/.bin, it uses that; otherwise it downloads the package into npm's cache and runs it without adding it to your project. Since npm 7, npx is essentially npm exec, and it asks for confirmation before installing something that isn't already available.
Typical uses:
npx create-vite@latest my-appnpx eslint .npx prettier@3 --check .Inside package.json scripts you don't need npx at all, because npm run already puts node_modules/.bin on the PATH.
node_modules/.binnpm run scripts already see local binariespackage.json?easyThe ones I use and look for most:
name and version: the package's identity, required if you publish.scripts: commands run with npm run <name>, like dev, build and test; npm test and npm start are shortcuts.dependencies, devDependencies, peerDependencies: what the package needs, as semver ranges.type: "module" makes .js files ES modules; without it they're CommonJS.main is the legacy entry point; exports is the modern one. exports defines exactly which paths consumers can import and can point import and require at different files (conditional exports).bin: executables to link onto the PATH, for CLIs.engines: supported Node versions, like ">=22".files: what gets published.private: true: blocks accidental publishing, common for apps.Also handy: workspaces for monorepos, imports for internal # aliases, and packageManager for Corepack.
name, version and scripts"type": "module" switches .js to ESMexports defines the public entry pointsbin, engines, files, privateLikely follow-up: What does the exports field prevent that main did not?
dependencies, devDependencies and peerDependencies?easyexpress or pg. They're installed wherever your package is installed.typescript, vitest or eslint. They aren't installed when someone else installs your package, and npm install --omit=dev skips them in production.react as a peer so the app ends up with exactly one React. Since npm 7, peers are installed automatically, and an incompatible version fails the install unless you pass --legacy-peer-deps.There's also optionalDependencies, whose install failures are tolerated. For a bundled app, the split mostly documents intent; for a published library, it decides what your users download.
--omit=dev skips dev dependencies in production^ and ~ ranges, and why lockfiles matter.easySemver versions are MAJOR.MINOR.PATCH: bump major for breaking changes, minor for backward-compatible features, patch for bug fixes.
Ranges in package.json:
^1.4.2 means >=1.4.2 <2.0.0: any compatible minor or patch. It's what npm install saves by default.~1.4.2 means >=1.4.2 <1.5.0: patches only.^0.4.2 means <0.5.0, because 0.x minors may break.Ranges mean two installs a month apart can get different versions. package-lock.json records the exact version, source and integrity hash of every package in the tree, including transitive ones, so every machine and CI run gets the same node_modules. Commit it, and use npm ci in CI: it installs exactly what the lockfile says and fails if the lockfile and package.json disagree.
^ allows minor and patch; ~ only patchnpm ci in CILikely follow-up: What is the difference between npm install and npm ci?
EventEmitter, and are its listeners called synchronously or asynchronously? What does this print?easyEventEmitter from node:events implements the observer pattern and underpins much of Node: streams, HTTP servers, sockets and process are all emitters. You subscribe with on, subscribe for a single call with once, unsubscribe with off, and trigger with emit(name, ...args).
emit() is synchronous: it calls every listener in registration order before returning. The snippet prints before, send receipt 42, first order! 42, send receipt 43, after. A listener that needs to defer work has to schedule it itself, for example with setImmediate.
Two details interviewers look for:
'error' event is special: emitting it with no listener throws, which usually crashes the process.MaxListenersExceededWarning, a hint that you may be leaking listeners.events.once(emitter, name) returns a promise, handy with await.
import { EventEmitter } from 'node:events';
const orders = new EventEmitter();
orders.on('paid', (id) => console.log('send receipt', id));
orders.once('paid', (id) => console.log('first order!', id));
console.log('before');
orders.emit('paid', 42);
orders.emit('paid', 43);
console.log('after');processon, once, off, emitemit() calls listeners synchronously, in order'error' event throwsBuffer in Node.js, and why does it exist?easyA Buffer is a fixed-length sequence of raw bytes. JavaScript strings are text, but files, sockets, images and crypto all deal in binary data, so Node needed a byte type. Today Buffer is a subclass of Uint8Array.
Key APIs:
Buffer.from('héllo', 'utf8') or Buffer.from(arrayBuffer) to create oneBuffer.alloc(size) gives zero-filled memory; Buffer.allocUnsafe(size) is faster but may contain old data, so only use it when you overwrite every bytebuf.toString('base64') (or 'hex', 'utf8') to convert backBuffer.concat([a, b]) to join chunksCharacters aren't bytes: 'é'.length is 1, but Buffer.byteLength('é') is 2. So when you collect a stream's chunks, don't decode each chunk separately, because a multi-byte character can be split across two chunks. Concatenate the Buffers first, or call setEncoding('utf8') on the stream.
Uint8Array subclassalloc zero-fills; allocUnsafe may hold old databyteLengthfs.readFileSync, fs.readFile and fs/promises, and when would you use each?easyAll three read the same file; they differ in how they wait.
readFileSync blocks the event loop until the file is read. That's fine in startup code and CLI scripts, like loading config once, but never in a request handler, where it stalls every other request.fs.readFile is non-blocking: the work runs on libuv's thread pool and the result arrives in an error-first callback.node:fs/promises offers the same non-blocking operations returning promises, so you can use async/await and try/catch. It's the default choice in modern code.All of them load the whole file into memory. For large files, or to send a file over HTTP, use fs.createReadStream and pipe it, so memory stays constant. Also avoid check-then-act code like existsSync followed by a read, which races: just attempt the operation and handle ENOENT.
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
const a = fs.readFileSync('config.json', 'utf8'); // blocks until done
fs.readFile('config.json', 'utf8', (err, b) => { // error-first callback
if (err) return console.error(err);
});
const c = await readFile('config.json', 'utf8'); // promise, works with awaitfs/promises with async/awaitstream.pipeline() preferred over .pipe()?hardBackpressure is what happens when a producer is faster than its consumer. Every writable has an internal buffer sized by highWaterMark (64 KiB by default for byte streams in Node 22). write() returns false once the buffer is past that limit; the producer should stop and wait for the 'drain' event, as the snippet does. Ignore it and data piles up in memory until the process runs out.
readable.pipe(writable) handles backpressure for you by pausing and resuming the source. What it doesn't handle is errors: if one stream fails, the others aren't destroyed, so you can leak file descriptors or leave sockets hanging, and you need an error listener on every stream.
pipeline(a, b, c) from node:stream/promises handles backpressure and errors: if any stream fails, it destroys all of them and rejects once, so a single try/catch around await pipeline(...) covers everything.
import { once } from 'node:events';
import { createWriteStream } from 'node:fs';
const out = createWriteStream('big.txt');
for (let i = 0; i < 1e6; i++) {
if (!out.write(`line ${i}\n`)) {
await once(out, 'drain'); // buffer is full: wait until it empties
}
}
out.end();write() returns false past highWaterMark'drain' before writing more.pipe() handles backpressure but not errorspipeline() destroys every stream on errorLikely follow-up: How would you gzip a large file and upload it without buffering it in memory?
With sessions, the server stores session data (in memory, Redis or a database) and gives the browser a random session ID in a cookie. Each request looks that ID up. Revoking is easy, just delete the session, but every server instance needs access to the shared session store.
A JWT is a signed token, header.payload.signature, carrying claims such as the user ID and expiry. Any server with the key can verify it without a lookup, which suits distributed services and APIs. The downsides: you can't easily revoke a token before it expires, and the payload is only base64url-encoded, not encrypted, so never put secrets in it. The usual mitigation is short-lived access tokens plus a revocable refresh token.
For a classic web app I'd default to sessions in an HttpOnly, Secure, SameSite cookie. JWTs fit stateless APIs, mobile clients and service-to-service auth. Either way, avoid localStorage for tokens, since any XSS can read it.
Likely follow-up: How would you log a user out everywhere if you use JWTs?
dotenv?easyConfiguration that changes between environments, like ports, database URLs, API keys and feature flags, should come from environment variables, read through process.env. Values are always strings, so parse numbers and booleans yourself, and validate everything once at startup so a missing variable fails fast instead of at 3 a.m.
For local development Node can load .env files itself, so dotenv is optional:
--env-file=.env loads a file; pass it several times and later files override earlier ones.--env-file-if-exists doesn't fail when the file is missing (Node 22.9+).process.loadEnvFile('.env') does the same from code.Variables already set in the real environment take precedence over values from the file. Keep .env out of git and commit a .env.example instead; in production, inject secrets from the platform or a secret manager. Expose the parsed, typed config from one module rather than reading process.env all over the codebase.
# .env
PORT=3000
DATABASE_URL="postgres://localhost/app"
node --env-file=.env server.js # load one file
node --env-file=.env --env-file=.env.local server.js # later files override earlier ones
node --env-file-if-exists=.env server.js # no error if the file is missingprocess.env; values are strings--env-file and process.loadEnvFile() replace dotenvCORS (Cross-Origin Resource Sharing) is a browser mechanism. The same-origin policy stops a page on https://app.example.com from reading responses from https://api.example.com unless the API opts in with response headers, mainly Access-Control-Allow-Origin.
For requests that aren't "simple", such as PUT or DELETE, a JSON Content-Type or custom headers like Authorization, the browser first sends a preflight OPTIONS request. The server must answer it with Access-Control-Allow-Methods and Access-Control-Allow-Headers, and can let the browser cache the answer with Access-Control-Max-Age.
In Express, the cors middleware handles all of this. Best practices:
*, for anything authenticated.Access-Control-Allow-Credentials: true, and then the origin can't be *.Access-Control-Allow-OriginOPTIONS preflight*Likely follow-up: Why does a request work in Postman or curl but fail in the browser?
node:http module, without Express?easyhttp.createServer() takes a request listener (req, res) that runs for every request. req is an IncomingMessage, a Readable stream with method, url and headers; the body isn't parsed for you, so you read the chunks yourself. res is a ServerResponse, a Writable stream: set the status and headers with writeHead(), or statusCode and setHeader(), then send data with write() and end().
There's no routing, body parsing or error middleware; that's exactly what Express, Fastify and similar frameworks add on top. The raw API still matters, because frameworks wrap these same objects, and streaming a response or handling a raw upload means treating req and res as streams.
Every request must finish with res.end(), or the client hangs. Also listen for 'error' on the server, for example EADDRINUSE when the port is taken. Use node:https with a key and certificate for TLS, and node:http2 for HTTP/2.
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/echo') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk); // req is a Readable
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(Buffer.concat(chunks));
}
res.statusCode = 404;
res.end('Not found');
});
server.listen(3000, () => console.log('listening on 3000'));createServer((req, res) => ...) runs per requestreq is a Readable; parse the body yourselfres is a Writable: writeHead, write, endGraceful shutdown means finishing in-flight work before exiting instead of dropping it. Kubernetes, Docker and PM2 send SIGTERM first and only force-kill with SIGKILL after a grace period (30 seconds by default in Kubernetes), so handle it:
server.close() stops listening, and its callback fires once existing connections have ended. Also fail your readiness check so the load balancer stops routing to you.close() open, so respond with Connection: close while draining or call server.closeIdleConnections().unref() stops that timer from keeping the process alive on its own.Handle SIGINT too, for Ctrl+C locally. In Docker, use the exec form, CMD ["node", "server.js"], so a wrapping shell doesn't swallow the signal.
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, draining');
server.close(async () => {
// runs once every open connection has ended
await db.end();
process.exit(0);
});
// safety net if a connection refuses to finish
setTimeout(() => process.exit(1), 10_000).unref();
});server.close() stops accepting, waits for connectionsLikely follow-up: Why might server.close() never call its callback?
I think in layers:
helmet sets sensible defaults such as Content-Security-Policy, Strict-Transport-Security and X-Content-Type-Options: nosniff, and removes X-Powered-By.HttpOnly, Secure, SameSite cookies, and check authorization on every resource, not just authentication.npm audit plus Dependabot or Renovate, and keep the dependency count low.helmetLikely follow-up: How would you protect a login endpoint against brute-force attacks?
A Node process runs your JavaScript on one core, so scaling means running more processes and keeping them interchangeable.
cluster module or PM2's cluster mode (pm2 start app.js -i max), which also restarts crashed processes and supports zero-downtime reloads.Health checks and graceful shutdown make scaling up, scaling down and rolling deploys safe.
cluster or PM2 cluster mode on one machineFirst confirm it's a leak: heapUsed from process.memoryUsage() or your metrics keeps climbing across garbage collections under steady load, until the process dies with JavaScript heap out of memory. Raising --max-old-space-size only buys time.
Usual suspects:
Map keyed by user, URL or request ID that's never evictedsetInterval timers that are never clearedTo find it, take heap snapshots: attach Chrome DevTools with --inspect, or write snapshots with v8.writeHeapSnapshot() or --heapsnapshot-signal. Take one, apply load, take another, and use the Comparison view to see which object types grew; the Retainers panel shows what keeps them alive.
Fixes: bounded LRU caches with TTLs, removing listeners and clearing timers during cleanup, and WeakMap for per-object metadata.
node --inspect server.js # chrome://inspect > Memory > take heap snapshots
node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid> # writes a Heap.*.heapsnapshot file to the cwd
# write up to 2 snapshots automatically when nearing the heap limit
node --heapsnapshot-near-heap-limit=2 --max-old-space-size=512 server.jsLikely follow-up: What is the difference between heapUsed, external and rss?
In-process memory, a Map or an LRU cache library, is the fastest option: no network hop, no serialization. But each process has its own copy, so four cluster workers or ten pods mean ten caches that can disagree. It's also lost on every restart and deploy, and it lives on the V8 heap, adding GC pressure, so it must be size-bounded. It's good for small, hot, rarely changing data like config or feature flags.
Redis is a shared cache: every instance sees the same data, it survives app restarts, and it offers TTLs, eviction policies and rich data structures. The cost is a network round trip, serialization and one more piece of infrastructure to run.
Common patterns: cache-aside (read the cache; on a miss load from the database and store it with a TTL), invalidating or updating on writes, and preventing a stampede when a hot key expires by coalescing concurrent misses. Many systems use both: a tiny in-process LRU in front of Redis.
require() an ES module, and can an ES module import CommonJS? What are the gotchas?hardESM importing CommonJS always works. The default import is module.exports. Named imports work only when Node's static analysis can detect them, which covers patterns like exports.add = ... but not every module.exports = ... assignment; otherwise you get a "named export not found" error, so import the default and destructure. createRequire(import.meta.url) gives you a real require inside ESM.
CommonJS requiring ESM used to throw ERR_REQUIRE_ESM, forcing await import(). Since Node 22.12 (and 20.19), require() loads ES modules synchronously by default and returns the module namespace object, with the default export under .default. The limit: if the module graph uses top-level await, it throws ERR_REQUIRE_ASYNC_MODULE. Dynamic import() works from both formats.
When publishing a package with both builds, watch for the dual package hazard: an app can load the CJS and ESM copies at the same time, giving duplicated state and failing instanceof checks.
// main.cjs (Node 22.12+)
const esm = require('./lib.mjs'); // works if lib.mjs has no top-level await
console.log(esm.default(), esm.answer);
// main.mjs
import pkg from './legacy.cjs'; // default import = module.exports
import { add } from './legacy.cjs'; // only if statically detectable
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);module.exportsrequire(esm) works by default since Node 22.12ERR_REQUIRE_ASYNC_MODULEprocess object, and how do you read command-line arguments?easyprocess is a global object describing the running Node process:
process.argv: command-line arguments. Index 0 is the Node executable and index 1 the script path, so user arguments start at process.argv.slice(2). For flags, the built-in util.parseArgs handles types, defaults and short aliases.process.env: environment variables, always strings.process.exit(code) ends immediately; setting process.exitCode lets pending work finish first. Non-zero means failure.process.cwd() is where the command was run from, not where the file lives.process.stdin, process.stdout and process.stderr are streams.process.pid, process.platform, process.memoryUsage(), process.uptime(), process.hrtime.bigint().process is also an EventEmitter: process.on('SIGTERM', ...) handles signals, and exit, uncaughtException and unhandledRejection are events on it too.
// node cli.mjs --name Ada -v build
import { parseArgs } from 'node:util';
console.log(process.argv.slice(2)); // [ '--name', 'Ada', '-v', 'build' ]
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
name: { type: 'string', default: 'world' },
verbose: { type: 'boolean', short: 'v' },
},
});
console.log(values.name, values.verbose, positionals); // Ada true [ 'build' ]process.argv.slice(2)util.parseArgs parses flags without dependenciesprocess.exitCode is gentler than process.exit()process.cwd() is not the script directoryprocess.on__dirname is not defined in ES modules. How do you get the current file's path, and how do path.join and path.resolve differ?easyIn ESM, __dirname, __filename and require don't exist. Each module has import.meta.url instead, a file: URL. Since Node 20.11 you also get import.meta.dirname and import.meta.filename, plain paths equivalent to the old globals, and they're stable as of Node 22.16. On older versions, derive them with fileURLToPath(import.meta.url) and path.dirname(). Another option is new URL('./data.json', import.meta.url), which fs functions accept directly.
Don't confuse these with process.cwd(): that's where the command was run from, so a relative path like readFile('data.json') breaks when someone starts the app from another directory. Build paths from the module's own directory.
As for path: path.join() concatenates segments with the platform separator and normalizes ... path.resolve() processes segments right to left until it has an absolute path, falling back to the current working directory.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
console.log(import.meta.dirname); // /app/src
console.log(import.meta.filename); // /app/src/server.js
// Equivalent that also works on older Node versions
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
path.join('a', '../b', 'c.txt'); // 'b/c.txt'
path.resolve('data', 'c.txt'); // '<current working dir>/data/c.txt'__dirname, __filename or requireimport.meta.dirname and import.meta.filenamefileURLToPath(import.meta.url)process.cwd() is not the module directorypath.resolve returns absolute; path.join concatenatesrequire() resolve and cache modules? What happens with circular dependencies?midResolution: require(x) first checks built-in modules like fs or node:path. A path starting with ./, ../ or / is tried as an exact file, then with .js, .json and .node appended, then as a directory using its package.json main or index.js. A bare name like express is looked up in node_modules in the current directory, then in each parent directory up to the root; the package's exports field, if present, decides which file you get.
Caching: the first require runs the module and stores it in require.cache, keyed by resolved filename. Later calls return the same module.exports without re-running the code, so a module is effectively a singleton, handy for a shared database pool.
Circular dependencies don't crash: when b requires a while a is still loading, b receives a's partially filled exports, as the snippet shows. Avoid cycles, or access imports lazily inside functions.
// a.js
exports.loaded = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.loaded = true;
// b.js
const a = require('./a');
console.log('in b, a.loaded =', a.loaded); // false: a.js is only half-run
exports.done = true;
// node a.js prints "in b, a.loaded = false", then "in a, b.done = true"node_modulesnode_modules foldersrequire.cache is keyed by resolved filenameLikely follow-up: How would you force a module to be re-executed in tests?
Never store plain text, and never use a fast hash like MD5 or SHA-256 on its own: GPUs can try billions of guesses per second. Use a slow, salted password hashing function:
argon2 packagenode:crypto, as in the snippetbcrypt or bcryptjs; it only uses the first 72 bytes of a passwordA unique random salt per password means identical passwords get different hashes and precomputed tables are useless. The cost factor keeps each guess expensive; raise it as hardware improves and rehash on the user's next login.
Details that matter: use the async APIs so hashing runs on the thread pool instead of blocking the event loop, compare with crypto.timingSafeEqual (the libraries do this for you), and rate-limit login attempts. Store the algorithm and parameters with the hash so you can migrate later.
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, 64);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
async function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':').map((h) => Buffer.from(h, 'hex'));
return timingSafeEqual(hash, await scryptAsync(password, salt, 64));
}Injection happens when untrusted input gets interpreted as code or query syntax.
{ "$gt": "" } where you expected a string. Validate types, or strip keys starting with $.exec runs through a shell, so ; or && in input runs extra commands. Use execFile or spawn with an argument array.../../etc/passwd. Resolve the path and check it stays inside the allowed directory.__proto__. Avoid naive merges; use Map or Object.create(null) for user-keyed data.The general defense is validation at the boundary: a schema (Zod, Ajv, Joi) that checks types, formats, lengths and allowed fields before data reaches your logic, plus a least-privilege database user.
// SQL: string-built queries are injectable
db.query(`SELECT * FROM users WHERE email = '${email}'`); // vulnerable
db.query('SELECT * FROM users WHERE email = $1', [email]); // parameterized
// NoSQL: a body of { "username": { "$gt": "" } } matches the first user
const user = await users.findOne({ username: req.body.username });
// Command: exec runs a shell, so file = "x.jpg; rm -rf ~" runs rm
exec(`convert ${file} out.png`); // vulnerable
execFile('convert', [file, 'out.png']); // arguments, no shellexecFile or spawn with arguments, not execREST models resources as URLs, like /users/42/orders, and uses HTTP semantics: verbs such as GET, POST and DELETE, status codes and caching headers. It's simple and works with HTTP caches and CDNs out of the box. The downsides are over-fetching (endpoints return fixed shapes) and under-fetching (a screen may need several round trips).
GraphQL exposes a single endpoint and a typed schema. The client sends a query naming exactly the fields it needs, including nested data, and gets it in one round trip; the schema doubles as documentation. The costs:
POST requests to one URL.errors array.I'd pick REST for public APIs, simple CRUD and cache-heavy reads, and GraphQL when many clients, like web and mobile, need different shapes of the same data.
Rate limiting caps how many requests a client can make in a time window, to stop brute-force logins, scraping and accidental overload. Decide on the key (IP address, API key or user ID) and the algorithm:
With several instances, an in-memory counter is wrong, because each instance would allow the full quota. Keep counters in Redis with an atomic INCR and an expiry, as in the snippet. Reject with 429 Too Many Requests and a Retry-After header.
In practice, express-rate-limit with a Redis store, or limiting at the API gateway or reverse proxy, is common. Behind a proxy, configure Express's trust proxy setting so req.ip is the client's address, not the load balancer's.
// Fixed window: at most 100 requests per IP per minute, across all instances
async function rateLimit(req, res, next) {
const windowId = Math.floor(Date.now() / 60_000); // current minute
const key = `rl:${req.ip}:${windowId}`;
const count = await redis.incr(key); // atomic, shared by every instance
if (count === 1) await redis.expire(key, 60);
if (count > 100) return res.status(429).json({ error: 'Too many requests' });
next();
}Retry-Aftertrust proxy behind a load balancerI think in three signals:
pino, with levels (debug, info, warn, error) configured per environment. Every line carries a request or correlation ID so you can follow one request across services; AsyncLocalStorage lets you attach it without passing it through every function. Never log passwords, tokens or personal data.Operationally, write logs to stdout and let the platform collect them instead of managing log files in the app. Add health and readiness endpoints, and make sure crashes log the error before the process exits.
AsyncLocalStorageFirst measure instead of guessing: reproduce with a load tool like autocannon and watch latency percentiles, CPU and event loop delay. High CPU plus growing event loop delay points at JavaScript hogging the main thread; low CPU with slow responses points at I/O, like a slow query or an exhausted connection pool or thread pool.
For CPU problems, record a CPU profile:
--inspect lets Chrome DevTools or VS Code attach; record a profile while the load runs.--cpu-prof writes a .cpuprofile on exit that DevTools can open.--prof plus node --prof-process gives a text summary of V8's tick log.Then read the flame graph: the widest bars are where the time goes. Typical culprits are big JSON work, regexes, synchronous crypto, sorting large arrays, or a *Sync call. Community tools like Clinic.js and 0x wrap this in friendlier flame graphs and diagnostics.
In production, prefer sampling or an APM, and never expose the inspector port publicly.
# 1. Reproduce under load
npx autocannon -c 50 -d 30 http://localhost:3000/report
# 2a. Attach Chrome DevTools (chrome://inspect) and record a CPU profile
node --inspect server.js
# 2b. Or write a .cpuprofile file when the process exits
node --cpu-prof server.js
# 2c. Or use V8's sampling profiler and summarize its log
node --prof server.js
node --prof-process isolate-*.log > profile.txt--inspect, --cpu-prof or --profThe language is the same (Node uses V8, Chrome's engine), but the host environment differs:
window, document and localStorage. Node has none of those, but gives you the file system, networking, child processes, process and Buffer.window in browsers and global in Node; globalThis works in both.express from node_modules.--permission.process.nextTick and setImmediate; browsers interleave rendering and requestAnimationFrame.Many web APIs are shared now: fetch, URL, AbortController, TextEncoder, Web Streams, structuredClone and crypto.subtle.
globalThis works in both environmentsfetch. How does it differ from fetch in the browser, and what should you watch out for?easyfetch has been a global since Node 18 and stable since Node 21. It's implemented by undici, Node's own HTTP client, and follows the same web standard, so Request, Response, Headers, FormData and AbortController work as they do in the browser.
Differences and gotchas:
res.ok or res.status.AbortSignal.timeout(ms), which makes fetch reject with a TimeoutError.res.body is a web ReadableStream; convert it with Readable.fromWeb() when you need a Node stream, for example to pipe into a file.For heavy use, undici's own API gives finer control over connection pools.
const res = await fetch('https://api.example.com/users/42', {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000), // fail after 5 s
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404 and 500 do not reject
const user = await res.json();res.okAbortSignal.timeout()ReadableStreamfor await...of, and how would you use it to process a large file line by line?midfor await...of loops over an async iterable, an object whose [Symbol.asyncIterator]() method hands out promises of values, awaiting each one before running the loop body.
Plenty of things in Node are async iterable:
fs streams and incoming HTTP requestsreadline interfaces, which yield one line at a time, so the snippet handles a multi-gigabyte log with constant memoryevents.on(emitter, 'name'), which turns events into an async sequencesetInterval from node:timers/promisesIt gives you backpressure for free: the stream only reads more when the loop asks for the next item, so slow processing slows reading down instead of buffering. Stream errors are thrown inside the loop, so a normal try/catch works, and break destroys the stream.
The trade-off is that iterations run sequentially. For independent work, like many HTTP calls, use promises with a concurrency limit instead. You can build your own async iterables with async function* generators.
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({ input: createReadStream('app.log'), crlfDelay: Infinity });
let errors = 0;
for await (const line of rl) {
if (line.startsWith('ERROR')) errors++;
}
console.log('errors:', errors);readline and events.on are async iterablebreak destroys the streamNode uses the web-standard AbortController: create a controller, pass its signal to the operation, and call controller.abort() to cancel. Many core APIs accept a signal:
fetch and http.requestfs.readFile and the fs/promises functionstimers/promises such as setTimeoutevents.once, stream.pipeline and child_process.spawnHelpers make signals composable: AbortSignal.timeout(ms) aborts after a delay, and AbortSignal.any([...]) aborts as soon as any of several signals does, as in the snippet, which combines a manual cancel with a timeout.
A cancelled operation rejects, usually with an AbortError (fetch reports a TimeoutError for AbortSignal.timeout), so check err.name and don't treat it as a real failure. In your own async functions, accept a signal option and call signal.throwIfAborted() at checkpoints or listen for its abort event. A common production use is stopping downstream calls when the client disconnects.
import { setTimeout as sleep } from 'node:timers/promises';
const controller = new AbortController();
res.on('close', () => controller.abort()); // e.g. the client went away
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]);
try {
const upstream = await fetch(url, { signal });
await sleep(100, null, { signal });
} catch (err) {
if (err.name === 'AbortError' || err.name === 'TimeoutError') console.log('cancelled');
else throw err;
}controller.signal; call abort() to cancelAbortSignal.timeout() and AbortSignal.any() composeAbortErrorsignal in your own async APIsunref(), refresh() and timers/promises for?midsetTimeout(fn, ms) schedules fn for the timers phase no earlier than ms from now; if the loop is busy, it runs late. A delay of 0 becomes 1 ms, and delays above 2147483647 ms (about 24.8 days) overflow to 1 ms with a warning. setInterval repeats, but if a callback runs long the next one is simply late; for work that must not overlap, chain setTimeout calls instead.
Unlike browsers, Node returns a Timeout object, not a number:
unref(): the timer no longer keeps the process alive, useful for background metrics or cleanup intervals.ref() undoes that, and hasRef() checks it.refresh(): restarts the countdown with the same callback, handy for idle timeouts.node:timers/promises offers promise versions: await setTimeout(1000) as a sleep, setImmediate(), and setInterval() as an async iterator for for await loops, all cancellable with an AbortSignal. Clear timers you no longer need: forgotten intervals are a classic leak.
Timeout objects, not numbersunref() stops a timer keeping the process aliverefresh() restarts an existing timertimers/promises gives awaitable, abortable timersA WebSocket starts as an HTTP request with an Upgrade: websocket header. After the 101 Switching Protocols response, the same TCP connection becomes a persistent, full-duplex channel where either side can send messages at any time. That beats polling for chat, live dashboards, notifications and multiplayer features.
In Node, the client is built in: Node 22 ships a browser-compatible global WebSocket. There's no built-in server, so you use the ws package (low-level and fast) or Socket.IO, which adds rooms, reconnection and fallbacks on top of its own protocol.
Production concerns:
bufferedAmount before flooding slow clients.If data only flows from server to client, Server-Sent Events are simpler.
WebSocket clientws or Socket.IOnode:test runner offer?midNode ships a test runner, node:test, stable since Node 20, so many projects don't need Jest or Vitest:
test(), or describe() and it(), with before, after, beforeEach and afterEach hooksnode:assert/strictmock.fn(), mock.method() and mock.timersnode --test finds files like *.test.js or anything in a test directory and runs each file in its own process; --watch reruns on change.only, skip, todo, --test-name-pattern, reporters like spec, tap and junit, and coverage with --experimental-test-coverageStrategy matters more than the runner: unit tests for pure logic, integration tests that start the app and hit it over HTTP (with supertest, or plain fetch against a random port), and a real database in a container rather than mocking everything. Inject dependencies, like the API client in the snippet, so they're easy to replace.
import { test, describe, mock } from 'node:test';
import assert from 'node:assert/strict';
import { sum, getUser } from './user.js';
describe('sum', () => {
test('adds numbers', () => assert.equal(sum(2, 3), 5));
});
test('getUser uppercases the name', async () => {
const api = { fetchUser: mock.fn(async () => ({ name: 'ada' })) };
assert.equal(await getUser(api, 1), 'ADA');
assert.equal(api.fetchUser.mock.callCount(), 1);
});
// Run with: node --testnode:test is built in and stabledescribe/it, hooks and node:assert/strictmock.fn, mock.method, mock.timersnode --test, --watch and a coverage flagThe key is to stream, never buffer: reading a 2 GB upload into memory per request will kill the process under load. Pipe the request straight to its destination with pipeline(), which applies backpressure, so a slow disk or network slows the client down instead of filling RAM.
For multipart/form-data, the browser's form format, use a streaming parser like busboy, or multer with disk storage rather than memory storage. For raw binary bodies, as in the snippet, the request itself is the stream.
Other essentials:
Content-Type.const MAX = 10 * 1024 * 1024; // 10 MB
app.put('/upload', async (req, res) => {
let size = 0;
const limit = new Transform({
transform(chunk, _enc, cb) {
size += chunk.length;
cb(size > MAX ? new Error('File too large') : null, chunk);
},
});
const path = join(UPLOAD_DIR, randomUUID()); // never trust the client's filename
await pipeline(req, limit, createWriteStream(path)); // streams with backpressure
res.status(201).json({ id: basename(path) });
});pipeline() gives backpressure and cleanupprocess.nextTick dangerous where recursive setImmediate is not?hardThe nextTick queue is drained completely before the event loop can move on, including callbacks added while it's draining. A function that keeps rescheduling itself with process.nextTick therefore never lets the loop reach the timers or poll phase: the timeout only fires after all million iterations. With unbounded recursion, timers, I/O and incoming requests would starve forever, even though nothing looks like a busy loop. Recursive promise callbacks behave the same way, because the microtask queue is also drained completely.
setImmediate callbacks run in the check phase, and any scheduled during that phase wait for the next iteration. The loop runs timers and I/O in between, so the timeout fires almost immediately.
Rule of thumb: use process.nextTick sparingly, for things like emitting an event after a constructor returns or making a callback API consistently asynchronous. To yield to the loop while chunking long work, use setImmediate.
let n = 0;
setTimeout(() => console.log('timeout fired after', n, 'iterations'), 0);
function tick() {
if (++n < 1_000_000) process.nextTick(tick);
}
tick();
// timeout fired after 1000000 iterations
// With setImmediate(tick) instead, it fires after only a handfulsetImmediatePromise.all(items.map(fn)) starts all 10,000 requests at once: you hit rate limits, exhaust sockets or memory, and overload the other service. A sequential for...of with await is safe but slow. The answer is a concurrency pool.
The snippet starts limit workers. Each worker loops, claims the next index and awaits the task; as soon as a task finishes, that worker takes the next item, so up to limit requests are always in flight. Claiming next++ needs no lock, because only one worker runs at a time between awaits. Results are stored by index to preserve input order.
Worth discussing:
{ status, value } objects like Promise.allSettled.AbortSignal.In real projects, p-limit or p-map implement exactly this.
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // no race: only one worker runs at a time between awaits
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// const pages = await mapLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));Promise.all on everything floods the downstream serviceAsyncLocalStorage, and how would you use it to attach a request ID to every log line?hardAsyncLocalStorage from node:async_hooks is storage that follows an asynchronous call chain, similar to thread-local storage in other languages. als.run(store, fn) runs fn with that store, and anything called from it, including code after an await, in setTimeout callbacks or in promise chains, can read it with als.getStore(). Concurrent requests each see their own store even though they interleave on one thread.
The classic use is request context: a middleware creates a store with a request ID (reusing an incoming x-request-id header if there is one), and the logger reads it, so every log line from that request is correlated without threading an ID through every function. The same mechanism powers tracing libraries like OpenTelemetry, per-request database transactions and tenant IDs.
Caveats: getStore() returns undefined outside run(), so handle that. Context can get lost in libraries that queue callbacks themselves, such as some connection pools; AsyncResource fixes that. And don't use it to hide ordinary function arguments.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
export function log(msg) {
const requestId = requestContext.getStore()?.requestId;
console.log(JSON.stringify({ requestId, msg }));
}
app.use((req, res, next) => {
requestContext.run({ requestId: req.get('x-request-id') ?? randomUUID() }, next);
});
// Anywhere downstream, even after awaits, log('charging card') includes the IDrun(store, fn) sets it; getStore() reads itundefined outside run()util.promisify do, and how would you implement it yourself?midutil.promisify(fn) converts a function that follows the error-first callback convention, with the callback as its last argument, into one that returns a promise. That lets older APIs work with async/await.
The implementation returns a wrapper that creates a promise, calls the original function with the caller's arguments plus its own callback, and rejects if err is set, otherwise resolves with the value. Using fn.call(this, ...) preserves this, so promisified methods still work.
Details worth mentioning:
util.promisify honors a util.promisify.custom symbol for functions whose callbacks don't fit the pattern. dns.lookup, for instance, calls back with two values, and its promisified version resolves with { address, family }.util.callbackify goes the other way.fs/promises, timers/promises, dns/promises and stream/promises. Prefer those to promisifying yourself.function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn.call(this, ...args, (err, value) => (err ? reject(err) : resolve(value)));
});
};
}
const readFile = promisify(fs.readFile);
const text = await readFile('notes.txt', 'utf8');err, resolve with the valuethis with fn.callutil.promisify.custom handles unusual signaturesAll three run JavaScript and TypeScript on the server; they differ in priorities.
.env loading, a permission model and TypeScript type stripping.--allow-net or --allow-read. It runs TypeScript natively, favors web-standard APIs, and bundles a formatter, linter and test runner. Deno 2 added strong npm and package.json compatibility.In practice Node is the safe default for production. Deno and Bun are worth considering where their tooling or security model helps, after checking that your dependencies work.
cluster module let several processes listen on the same port, and what are its limitations?hardThe primary process forks workers with cluster.fork(), which uses child_process.fork() under the hood, so each worker is a full Node process with its own memory and event loop, connected to the primary over IPC. When a worker calls listen(3000), it doesn't bind the port itself: the primary owns the listening socket.
By default on every platform except Windows, the primary uses round-robin scheduling: it accepts each connection and hands the socket to the next worker. The alternative, SCHED_NONE, lets workers accept from the shared socket directly, which in practice spreads load unevenly.
Limitations:
exit.import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on('exit', (worker, code) => {
console.log(`worker ${worker.process.pid} died (${code}), restarting`);
cluster.fork();
});
} else {
http.createServer((req, res) => res.end(`pid ${process.pid}\n`)).listen(3000);
}flush?hardA Transform is a duplex stream whose output is computed from its input. You implement two hooks, either as constructor options or by subclassing and defining _transform and _flush:
transform(chunk, encoding, callback) runs for every incoming chunk. Emit output with callback(null, data), or call this.push() any number of times and then callback(). Passing an error to callback fails the stream. Calling callback is what signals you're ready for the next chunk, which is how backpressure propagates.flush(callback) runs once after the last chunk, before the stream ends. You need it whenever you buffer or aggregate: emitting a digest as in the snippet (used as pipeline(source, checksum(console.log), destination)), the last partial line of a line splitter, or the closing bracket of a JSON array.Set objectMode (or readableObjectMode) when you emit JavaScript objects instead of bytes, for example a CSV parser yielding rows.
For simple cases, pipeline() also accepts an async generator as a step, like async function* (source) { for await (const chunk of source) yield transform(chunk); }, which is often shorter and easier to read.
import { Transform } from 'node:stream';
import { createHash } from 'node:crypto';
function checksum(onDone) {
const hash = createHash('sha256');
return new Transform({
transform(chunk, _enc, cb) {
hash.update(chunk);
cb(null, chunk); // pass data through unchanged
},
// flush runs once, after the last chunk
flush(cb) { onDone(hash.digest('hex')); cb(); },
});
}transform(chunk, enc, callback) handles each chunkcallback(null, data) or this.push()callback propagates backpressureflush emits buffered or aggregate data at the endHTTP/1.1 keep-alive reuses one TCP connection for many requests, avoiding a new TCP and TLS handshake each time. Load balancers keep a pool of idle connections to your servers and reuse them.
The trap is mismatched idle timeouts. Node's server.keepAliveTimeout defaults to 5 seconds: after 5 idle seconds, Node closes the connection. Many load balancers keep idle connections much longer; an AWS Application Load Balancer, for example, defaults to 60 seconds. If the balancer sends a request on a connection just as Node closes it, the request fails and the client sees a 502.
The fix is to make Node's timeout longer than the balancer's, for example server.keepAliveTimeout = 65_000, and to check the related limits: headersTimeout (60 s by default) and requestTimeout (5 minutes).
On the client side, reuse connections too. fetch (undici) pools connections automatically, and since Node 19 the default http.Agent has keep-alive enabled. Opening a new connection for every outgoing request wastes CPU and can exhaust ephemeral ports.
keepAliveTimeout above the balancer idle timeoutLikely follow-up: What happens to keep-alive connections during a graceful shutdown?
Node exits when the event loop has nothing left to wait for: no active, ref'ed handles (servers, sockets, timers, child processes, file watchers) and no pending requests such as an in-flight file read or DNS lookup. A promise is not a handle. If nothing will ever settle it, the process just exits; with a top-level await in an ES module, Node warns about an unsettled top-level await and exits with code 13.
Refusing to exit is the opposite problem: something is still ref'ed, often a database pool, an interval or a keep-alive socket. That's what makes CLI scripts and test runs hang at the end.
Tools:
unref() on timers, servers and sockets lets the process exit even while they're active; ref() reverses it.process.getActiveResourcesInfo() lists what's keeping the loop alive.beforeExit event fires when the loop empties, but not on process.exit().process.exit() ends immediately and can cut off pending stdout writes; prefer setting process.exitCode and closing resources.import net from 'node:net';
const server = net.createServer().listen(0); // active handle
const timer = setInterval(() => {}, 1000); // active handle
new Promise(() => {}); // not a handle: it doesn't keep the process alive
console.log(process.getActiveResourcesInfo()); // [ 'TCPServerWrap', 'Timeout' ]
server.unref();
timer.unref();
// Nothing ref'ed is left, so the process exits with code 0unref() stops a handle from blocking exitprocess.exitCode over process.exit()No questions match that filter.