The Node.js runtime
How Node.js works under a backend: modules and packages, the event loop and libuv, streams, errors and shutdown, scaling and diagnostics. In a .NET developer’s interview this is where they check whether you understand how a single event thread differs from the ASP.NET Core thread pool and what that means in production.
Before this topic: JavaScript and TypeScript.
How do modules and packages work in Node.js?
Section titled “How do modules and packages work in Node.js?”What it is
- Module — a file with its own scope: only what it exports is visible outside.
- Package — a folder with a
package.json: name, version, dependencies, scripts.
Two module systems
| CommonJS | ES modules | |
|---|---|---|
| Import | const fs = require('node:fs') |
import fs from 'node:fs' |
| Export | module.exports = { f } |
export function f() {} |
| When it applies | .cjs or no "type": "module" |
.mjs or "type": "module" |
How to use
npm install expressadds the dependency topackage.jsonand installs the package intonode_modules.package-lock.jsonpins exact versions of the whole tree — commit it.- CI installs with
npm ci: strictly from the lockfile.
Common mistake
Using require in an ES module. The result is ReferenceError: require is not defined in ES module scope.
Takeaway: modules are loaded with import or require, packages are described by package.json, and exact versions by the lockfile.
How it works
- CommonJS.
requiresynchronously reads and runs the file on the first call and caches the result. The export is a plain object. - ESM. The loader first parses every
importand builds the graph, then evaluates the modules. Imports are live bindings to exports, not copies. - The type of a
.jsfile is set by the nearestpackage.json. Since Node 22.7, a file with no"type"field but withimportsyntax is detected as ESM automatically.
Versions and ranges
| Spec | What it allows |
|---|---|
^1.2.3 |
from 1.2.3 up to, but not including, 2.0.0 |
~1.2.3 |
from 1.2.3 up to, but not including, 1.3.0 |
^0.2.3 |
from 0.2.3 up to 0.3.0: in 0.x a minor bump counts as breaking |
1.2.3 |
exactly this version |
Pitfalls
npm installadjusts the lockfile topackage.json, whilenpm ciinstalls strictly from the lockfile and fails if the two disagree.- In ESM, relative imports need the extension:
import './db.js', not'./db'. - ESM has no
__dirnameand norequire. Useimport.meta.dirnameandcreateRequireinstead.
Rule of thumb
New project — "type": "module", the lockfile in git, npm ci in CI.
Takeaway: CommonJS loads synchronously on call, ESM loads the whole graph up front, and versions are pinned by the lockfile, not by ranges.
Under the hood
require()of an ES module works without a flag since Node 22.12 and 20.19 and is stable since 24.15 and 25.4. If the graph has top-levelawait, you getERR_REQUIRE_ASYNC_MODULE.- The
"exports"field inpackage.jsondefines the public entry points and theimportandrequireconditions. All other files of the package are closed to imports. - Type stripping is on by default since 22.18 and 23.6 and stable since 24.12 and 25.2. Node replaces types with whitespace, ignores
tsconfig.jsonand does not check types. enum,namespacewith runtime code and parameter properties are not supported —ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Type imports needimport type, the.tsextension in imports is mandatory, and files undernode_modulesare not processed.
Trade-offs
| npm | pnpm | |
|---|---|---|
node_modules |
flat: any package in the tree is visible | isolated: only declared ones are visible |
| Disk | a copy per project | a shared store and hard links |
| Dependency install scripts | run | not run by default since pnpm 10 |
TypeScript without a build is great for scripts and tooling. For a service you still check types with tsc --noEmit in CI, and the erasableSyntaxOnly option (TypeScript 5.8) forbids syntax Node cannot erase.
How to check in production
npm ls packageshows where a version came from in the tree.overridesinpackage.jsonbumps a vulnerable transitive dependency.npm auditin CI and disabled install scripts close off the typical supply-chain attacks.
Takeaway: ESM by default, require(esm) for interop, a lockfile and a strict package manager against surprises in dependencies.
// main.mjs — an ES moduleimport { readFile } from 'node:fs/promises';import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); // the only way to get require inside ESMconst os = require('node:os'); // CommonJS module: loaded synchronously
console.log(typeof module, typeof __dirname); // undefined undefinedconsole.log(import.meta.filename.endsWith('main.mjs')); // true
const source = await readFile(import.meta.filename, 'utf8'); // top-level await — ESM onlyconsole.log(source.length > 0, typeof os.cpus); // true function// order.ts — run with: node order.ts (Node 22.18+ or 23.6+)type Order = { id: number; total: number };
function sum(orders: Order[]): number { return orders.reduce((acc, o) => acc + o.total, 0);}
console.log(sum([{ id: 1, total: 10 }, { id: 2, total: 5 }])); // 15
// enum Status { New, Paid } — ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXconst Status = { New: 'new', Paid: 'paid' } as const; // enum replacement, no transform neededtype Status = (typeof Status)[keyof typeof Status];const s: Status = Status.Paid;console.log(s); // paid- How does
^1.2.3differ from~1.2.3? — The first allows new minor and patch versions, the second only patches. - Why commit
package-lock.json? — So every machine and CI installs the same version tree. - Can you
requirean ES module? — Since Node 22.12, yes, if its graph has no top-levelawait. - What is a phantom dependency? — Importing a package that is not in your
package.jsonbut that npm hoisted into a flatnode_modules. - Does Node check types when running
.ts? — No, it only strips them; checking istsc --noEmit.
How does Node.js handle thousands of connections on one thread?
Section titled “How does Node.js handle thousands of connections on one thread?”What it is
- Node.js — a JavaScript runtime outside the browser: the V8 engine plus the libuv library for I/O.
- Event loop — a loop that picks up ready events and calls their callbacks one at a time. An event is a request arriving, the database answering, a timer firing.
How it works on a server
- A request arrives — the handler sends a query to the database and yields control at
await. - The thread is free and accepts the next requests.
- The database answers — the rest of the handler is queued, and the event loop runs it.
Common mistake
Synchronous work in a handler: fs.readFileSync, JSON.parse of a huge string, a heavy loop. While it runs, the server answers nobody.
Takeaway: one thread holds thousands of connections because it never waits for I/O; it only runs callbacks.
How it works
The main phases of one loop iteration, in order:
- timers — callbacks of
setTimeoutandsetIntervalwhose time has come. - poll — new I/O events and their callbacks. If there is nothing to do, the loop waits here.
- check —
setImmediatecallbacks. - close callbacks —
'close'events, for example on a socket.
After each callback Node drains the whole process.nextTick queue, then all promise microtasks.
Who waits for I/O
| Operation | Where it waits |
|---|---|
| Sockets: HTTP, TCP, DB drivers | the OS: epoll, kqueue, IOCP |
fs, dns.lookup, crypto.pbkdf2, zlib |
the libuv thread pool, 4 by default |
Pitfalls
- Recursive
process.nextTickor an endless promise chain keeps the loop from ever reaching I/O. - The order of
setTimeout(fn, 0)andsetImmediatein the main module is not deterministic. Inside an I/O callback,setImmediatealways goes first. - A pool of 4 threads: the fifth
crypto.pbkdf2waits until a thread frees up.
Rule of thumb
To defer work, use setImmediate — the Node docs recommend it. Heavy computation goes off the main thread.
Takeaway: the OS waits on sockets, the libuv pool on files and crypto, and JavaScript runs on one thread through the loop phases.
Under the hood
- Since libuv 1.45 (Node 20), timers run after the poll phase rather than before it.
- ES modules are evaluated by an asynchronous loader, so at the top level of an ES module a promise fires before
process.nextTick. In CommonJS it is the other way round. dns.lookupcallsgetaddrinfoon the pool, andhttpandnetuse it by default.dns.resolve*goes through c-ares and bypasses the pool.- The pool is shared by the whole process, up to 1024 threads via
UV_THREADPOOL_SIZE.
Trade-offs
| Node.js | ASP.NET Core | |
|---|---|---|
| Where request code runs | the single event loop thread | .NET thread pool threads |
| CPU work in a request | delays every request | occupies one thread |
| Cores | one process — one core for JS | all cores in one process |
| Synchronous I/O | stalls the server | leads to thread pool starvation |
How to check in production
perf_hooks.monitorEventLoopDelay()gives a histogram of loop delay. p99 rising to tens of milliseconds is a sign of blocking.performance.eventLoopUtilization()is the share of time the loop is busy. Close to 1 means the process is CPU-bound.- A CPU profile shows the function holding the thread:
--cpu-profor DevTools. - Slow file and DNS operations with idle CPU are a reason to raise
UV_THREADPOOL_SIZEat process start.
Takeaway: the Node model wins on waiting for I/O and loses on CPU, so you measure loop delay the way you measure the thread pool in .NET.
// phases.cjs — CommonJSconst fs = require('node:fs');
fs.readFile(__filename, () => { // I/O callback: poll phase setTimeout(() => console.log('timeout'), 0); // 5: timers phase, next iteration setImmediate(() => console.log('immediate')); // 4: check phase, right after poll Promise.resolve().then(() => console.log('promise')); // 3: microtask process.nextTick(() => console.log('nextTick')); // 2: before promises console.log('poll callback'); // 1});The numbers in the comments are the output order.
// delay.cjs — CommonJSconst { monitorEventLoopDelay } = require('node:perf_hooks');const crypto = require('node:crypto');
const histogram = monitorEventLoopDelay({ resolution: 10 });histogram.enable();setTimeout(() => { const start = Date.now(); while (Date.now() - start < 200) {} // 200 ms of synchronous work}, 10);setTimeout(() => { histogram.disable(); console.log(histogram.max / 1e6 > 150); // true: the loop stalled for ~200 ms}, 500);
const start = Date.now();for (let i = 1; i <= 6; i++) { crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => { console.log(i, Date.now() - start); // 1–4 finish together, 5 and 6 about twice as late });}With UV_THREADPOOL_SIZE=6, all six hashes finish at roughly the same time.
- Which runs first:
process.nextTickorPromise.then? — In CommonJSnextTick; at the top level of an ES module, the promise. - Which operations go to the libuv pool? — Files,
dns.lookup,crypto.pbkdf2andscrypt, asynczlib; sockets do not. - Why
setImmediateif there issetTimeout(fn, 0)? — It runs right after the current iteration’s I/O and does not depend on timers. - How do you tell the loop is blocked? —
monitorEventLoopDelayandeventLoopUtilization, then a CPU profile. - How does the model differ from ASP.NET Core? — There, requests run on the thread pool across all cores; in Node, one JS thread per process.
How do you work with streams and errors in Node.js?
Section titled “How do you work with streams and errors in Node.js?”What it is
- Stream — an object through which data flows in portions (chunks).
- Readable — a source: a file, an HTTP request body. Writable — a destination: a file, an HTTP response. Transform — changes data on the way: compression, parsing.
How to use
- Connect:
await pipeline(source, transform, destination)fromnode:stream/promises. - Read in code:
for await (const chunk of stream).
How to catch errors
| API style | How to catch |
|---|---|
promises and async |
try/catch around await |
(err, data) callback |
check err first |
EventEmitter and streams |
an on('error') listener |
Common mistake
Reading a large file with fs.readFile: it lands in memory in full. For large data use fs.createReadStream and pipeline.
Takeaway: data goes through streams and pipeline, errors are caught the way the API style dictates.
How it works
- A stream has an internal buffer of size
highWaterMark.write()returnsfalsewhen a Writable’s buffer is full, and the source must wait for the'drain'event. - That is backpressure: a slow destination slows down a fast source instead of piling data up in memory.
pipelinedoes this for you. When any stage fails, it destroys all streams and rejects the promise.
When to choose what
| Task | Tool |
|---|---|
| Connect streams | pipeline from node:stream/promises |
| Read a stream in code | for await...of |
| Custom transformation | Transform or an async generator inside pipeline |
Pitfalls
.pipe()does not forward errors: a failing source leaves the destination open.'error'with no listener on anEventEmitter— an exception and a crashed process.- Ignoring
falsefromwrite()— the buffer grows until memory runs out. - An
asyncevent listener.EventEmitterdoes not catch its rejected promise, and you get an unhandled rejection.
Rule of thumb
Streams only through pipeline; every long-lived EventEmitter gets an 'error' handler.
Takeaway: pipeline gives you backpressure and closes every stage on error, while 'error' with no listener crashes the process.
Under the hood
- An exception that reaches the event loop is an
uncaughtException. By default Node prints the stack and exits with code 1. - Since Node 15, a rejected promise with no handler by default (
--unhandled-rejections=throw) emitsunhandledRejection. If there is no listener, it becomes an uncaught exception. - The Node docs say it plainly: resuming normal operation after
uncaughtExceptionis not safe.
Graceful shutdown
docker stopor Kubernetes sends SIGTERM, and SIGKILL after a deadline.server.close()stops accepting connections. Since Node 19 it closes idle keep-alive connections right away.- In-flight requests get a timeout shorter than the orchestrator’s deadline — 30 seconds by default in Kubernetes.
- Close DB pools and broker clients, then exit with code 0.
Trade-offs
A global handler easily turns into an error silencer, but a process in an unknown state is more dangerous than a crashed one. Several instances and a fast restart are more reliable.
How to check in production
- Subscribing to
SIGTERMremoves the default exit: withoutprocess.exitthe process lives on until SIGKILL. - Node is not designed to run as PID 1: in Docker start it with
--initor via tini, and withCMD ["node", "dist/main.js"]rather thannpm start. - Exit code 137 on a container means SIGKILL: shutdown missed the deadline or the OOM killer fired.
Takeaway: errors not handled on the spot are logged and end the process, and shutdown on SIGTERM is written explicitly and with a timeout.
// pipe.mjs — an ES moduleimport { pipeline } from 'node:stream/promises';import { Readable, Transform, Writable } from 'node:stream';
const upper = () => new Transform({ transform(chunk, _encoding, callback) { callback(null, chunk.toString().toUpperCase()); },});const collect = (parts) => new Writable({ write(chunk, _encoding, callback) { parts.push(chunk.toString()); callback(); },});
const parts = [];await pipeline(Readable.from(['a', 'b', 'c']), upper(), collect(parts));console.log(parts.join('')); // ABC
const broken = new Transform({ transform(_chunk, _encoding, callback) { callback(new Error('bad chunk')); },});const source = Readable.from(['x']);try { await pipeline(source, broken, collect([]));} catch (err) { console.log(err.message, source.destroyed); // bad chunk true: pipeline closed the source too}// server.mjs — an ES moduleimport http from 'node:http';import { EventEmitter } from 'node:events';
const bus = new EventEmitter();try { bus.emit('error', new Error('nobody listens')); // 'error' with no listener throws} catch (err) { console.log(err.message); // nobody listens}
process.on('unhandledRejection', (reason) => { console.error('unhandled rejection:', reason); // log and exit process.exit(1);});
const server = http.createServer((req, res) => res.end('ok'));server.listen(3000);
process.once('SIGTERM', () => { // docker stop, Kubernetes console.log('SIGTERM: draining'); server.close(() => process.exit(0)); // no new connections, wait for current ones setTimeout(() => process.exit(1), 10_000).unref(); // safety net against hung requests});- What is backpressure and how does
pipelinerespect it? — The destination slows the source:write()returnedfalse, so wait for'drain';pipelinedoes it for you. - Why is
pipelinebetter than.pipe()? — It propagates errors and destroys every stream, and fromnode:stream/promisesyou canawaitit. - What happens if an
EventEmitterhas no'error'listener? —emit('error')throws and the process crashes. - Can you keep running after
uncaughtException? — No: log and exit, the orchestrator restarts the process. - Why does a container with
npm starttake long to stop? — npm does not pass SIGTERM to the Node process, so it gets SIGKILL at the deadline.
How do you scale and profile a Node.js service?
Section titled “How do you scale and profile a Node.js service?”What it is
- Scaling — running several processes to use every core and machine.
- Profiling — measuring where CPU and memory go.
How to use
- Several instances behind a load balancer: Nginx, a Kubernetes Service.
- Heavy computation goes to
worker_threadsso it does not block the event loop. node --inspect app.js, thenchrome://inspectin Chrome: the Performance and Memory tabs.
Common mistake
Keeping sessions or a cache in process memory. With several instances each sees its own, so shared state lives in Redis or the database.
Takeaway: processes per core and machine, CPU work in workers, and bottlenecks found with a profiler.
When to choose what
| Option | When |
|---|---|
| Several pods or containers | the default in Kubernetes and Docker |
cluster or PM2 |
a single machine without an orchestrator |
worker_threads |
a CPU task inside the process: hashing, parsing, image processing |
How it works
- cluster. The primary process accepts connections and hands them to worker processes round-robin (except on Windows). Processes do not share memory.
- worker_threads. Threads in one process, each with its own V8 isolate, heap and event loop. Data is passed via
postMessage(a copy or a transfer) orSharedArrayBuffer. - The Node docs say it directly: workers help with CPU-intensive work and barely help with I/O.
Pitfalls
- A worker per request means an expensive start every time. Use a worker pool, for example the piscina library.
- Typical leaks: a global
Mapused as an unbounded cache, timers never cleared, event listeners nobody removes. The last one triggersMaxListenersExceededWarning.
Rule of thumb
I/O-bound — more instances. CPU-bound — workers or a separate service.
Takeaway: processes scale I/O, workers scale computation, and the orchestrator does cluster’s job better than cluster.
Under the hood: memory
- Node passes V8 a memory limit that accounts for cgroups. By default the old generation gets about half of the container’s memory, capped at a few gigabytes.
- Process memory (RSS) is the heap plus
Bufferdata outside the heap, code and worker stacks. So--max-old-space-sizeis set with headroom below the container limit. Since Node 24.7 there is also--max-old-space-size-percentage. - If the heap hits its limit —
FATAL ERROR: Reached heap limitwith a stack. If the container hits its limit first — SIGKILL and exit code 137 with no diagnostics.
How to check in production
--heapsnapshot-near-heap-limit=2writes heap snapshots before an OOM,--heapsnapshot-signal=SIGUSR2on a signal. A snapshot blocks the thread and needs about twice the heap size in memory.- Two snapshots are compared in DevTools (Memory → Comparison): look for growing object types and retainer chains.
kill -USR1 <pid>turns on the inspector in a running process.--inspectlistens on127.0.0.1:9229; never expose it.- Traces and metrics come from OpenTelemetry: the SDK is loaded before the app (
--importor--require). Libraries publish events throughdiagnostics_channelwithout monkey-patching modules.
Trade-offs: versions
- Node 24 is Active LTS until October 20, 2026, then maintenance until April 2028. Node 22 is in maintenance until April 2027. Node 20 has been unsupported since April 2026.
- Starting with Node 27 there is one major release a year, in April, and each becomes LTS in October. No more odd-numbered Current releases.
Takeaway: scale with processes, size memory to the container limit, catch leaks with heap snapshots, and pick the version from LTS.
// fib.mjs — an ES module: CPU work in worker_threadsimport { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
if (isMainThread) { let ticks = 0; const timer = setInterval(() => ticks++, 10); // the main loop keeps running const worker = new Worker(new URL(import.meta.url), { workerData: 38 }); worker.once('message', (result) => { clearInterval(timer); console.log(result, ticks > 10); // 39088169 true });} else { const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)); // computed on a separate thread parentPort.postMessage(fib(workerData));}If you compute fib(38) on the main thread, setInterval does not fire even once until the computation ends.
- How does
clusterdiffer fromworker_threads? — cluster is processes with a shared port and separate memory; workers are threads in one process with their own isolates. - Do you need cluster in Kubernetes? — Usually not: scale with pods, and the orchestrator restarts and balances.
- How do you find a memory leak? — Two or three heap snapshots under load, compare them in DevTools, look for growing objects and what retains them.
- Why was the container killed with code 137 with no
heap out of memoryin the log? — The container limit ran out before the V8 heap limit; lower--max-old-space-size. - Which Node version goes to production? — LTS, Active or Maintenance; in October 2026 that is Node 24, and after October 28 also Node 26.