Overview
Node.js is not just "JavaScript on the server" — it's a specific architecture built for I/O-bound workloads. Understanding libuv's thread pool (why file reads don't block despite single-threaded JS), streams (why you should never readFileSync a large file), and cluster vs worker_threads (two different answers to two different problems) separates Node.js users from Node.js engineers.
Architecture
NODE.JS INTERNAL ARCHITECTURE
════════════════════════════════════════════════════════
JavaScript Code (your app)
│
┌───────▼────────────────────────────────────────┐
│ Node.js Bindings (C++) │
│ fs, net, http, crypto, child_process, ... │
└───────┬────────────────────────────────────────┘
│
┌───────▼────────────────────────────────────────┐
│ libuv │
│ │
│ Network I/O → OS async (epoll/kqueue/IOCP) │
│ File I/O → Thread pool (4 threads default) │
│ DNS → Thread pool (c-ares) │
│ Timers → min-heap of timers │
└───────┬────────────────────────────────────────┘
│ calls back to JS when ready
▼
Event Loop (JS thread resumes callback)
UV_THREADPOOL_SIZE=8 → more parallel file ops
Network I/O never uses thread pool (OS-level async)
STREAM PIPELINE WITH BACKPRESSURE
════════════════════════════════════════════════════════
ReadableStream ──▶ Transform ──▶ WritableStream
│ │
│ highWaterMark = 64KB │ highWaterMark = 64KB
│ │
│ write() returns false ───────┘ (backpressure signal)
│ pause reading until 'drain' fires
Without backpressure: Readable produces faster than Writable consumes
→ data accumulates in memory → OOM
stream.pipe() handles backpressure automatically.
pipeline() (Node 10+) also handles cleanup on error.
CLUSTER vs WORKER_THREADS
════════════════════════════════════════════════════════
Cluster: Worker Threads:
Multiple Node.js processes Multiple threads in one process
Separate V8 heap per process Shared V8 heap via SharedArrayBuffer
No shared memory Can share memory (Atomics)
OS load-balances connections Explicit message passing (postMessage)
Best for: I/O scaling (HTTP) Best for: CPU-bound work
PM2 / Node cluster module worker_threads module
Technical Implementation
Transform Stream with Backpressure
import { Transform, pipeline } from 'stream';
import { promisify } from 'util';
import fs from 'fs';
const pipelineAsync = promisify(pipeline);
// Transform stream: uppercase each line, handle backpressure via highWaterMark
class UpperCaseTransform extends Transform {
constructor() {
super({
highWaterMark: 64 * 1024, // 64KB buffer before backpressure kicks in
decodeStrings: false,
});
}
_transform(chunk: Buffer, encoding: string, callback: () => void) {
this.push(chunk.toString().toUpperCase());
callback(); // signal: ready for next chunk
}
}
// pipeline handles backpressure + cleanup on error automatically
async function transformFile(src: string, dst: string) {
await pipelineAsync(
fs.createReadStream(src, { highWaterMark: 64 * 1024 }),
new UpperCaseTransform(),
fs.createWriteStream(dst)
);
console.log('Done — only 64KB in memory at any time');
}
// Manual backpressure handling (without pipe):
function streamWithBackpressure(readable: NodeJS.ReadableStream, writable: NodeJS.WritableStream) {
readable.on('data', (chunk) => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause(); // backpressure: stop reading
writable.once('drain', () => {
readable.resume(); // writable ready again, resume reading
});
}
});
readable.on('end', () => writable.end());
}
Express Middleware Chain with Error Handling
import express, { Request, Response, NextFunction } from 'express';
const app = express();
// 1. Request parsing
app.use(express.json({ limit: '1mb' }));
// 2. Logging middleware
app.use((req: Request, _res: Response, next: NextFunction) => {
const start = Date.now();
req.on('end', () =>
console.log(`${req.method} ${req.path} ${Date.now() - start}ms`)
);
next(); // pass to next middleware — MUST call next() or request hangs
});
// 3. Auth middleware (async)
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
req.user = await verifyToken(token);
next();
} catch (err) {
next(err); // passes error to error handler (skips remaining normal middleware)
}
};
// 4. Route
app.get('/api/orders', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const orders = await orderService.findByUser(req.user!.id);
res.json(orders);
} catch (err) {
next(err); // always call next(err) in async handlers
}
});
// 5. Error handler — MUST have 4 parameters (err, req, res, next)
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
});
});
Cluster for HTTP Load Distribution
import cluster from 'cluster';
import os from 'os';
import http from 'http';
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Primary ${process.pid} started, forking ${numCPUs} workers`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died (${signal || code}), respawning`);
cluster.fork(); // auto-restart crashed workers
});
// Graceful shutdown: drain existing connections
process.on('SIGTERM', () => {
console.log('Primary shutting down...');
for (const id in cluster.workers) {
cluster.workers[id]?.kill('SIGTERM');
}
setTimeout(() => process.exit(0), 10_000); // force exit after 10s
});
} else {
// Each worker is a separate Node.js process with its own event loop
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end(`Hello from worker ${process.pid}\n`);
});
server.listen(3000, () => {
console.log(`Worker ${process.pid} listening on :3000`);
});
process.on('SIGTERM', () => {
server.close(() => process.exit(0)); // stop accepting, finish in-flight
});
}
Interview Preparation
Q: Why is Node.js single-threaded but non-blocking?
Node.js has one JavaScript thread but relies on libuv for I/O, which uses the operating system's native async I/O mechanisms. For network sockets, libuv uses epoll (Linux), kqueue (macOS), or IOCP (Windows) — event-driven interfaces where the OS notifies you when data is ready without blocking a thread. For file system operations (which most OSes don't support truly async), libuv uses a thread pool (4 threads by default) — file reads run on a pool thread, and when complete, the callback is posted to the event loop for the JS thread to pick up. The JS thread never waits; it only executes callbacks when results are ready.
Q: How do you handle CPU-intensive work in Node.js without blocking the event loop?
Two options: (1) worker_threads — spawn a thread in the same process, pass the data, get the result back via postMessage. Workers can access SharedArrayBuffer for high-performance zero-copy data sharing. Best for: image processing, crypto, large JSON transforms. (2) child_process.fork() — spawn a separate Node.js process. Fully isolated memory. Best for: running separate scripts, untrusted code, processes that might crash. Avoid: doing synchronous heavy computation in the main event loop — even blocking for 50ms causes noticeable latency spikes.
Q: What is backpressure in Node.js streams and how do you handle it?
Backpressure occurs when a writable stream cannot consume data as fast as a readable produces it. Without backpressure handling, data accumulates in memory (the internal buffer grows without bound) until the process runs out of memory. Node.js streams signal backpressure through the write() return value: when the internal buffer exceeds highWaterMark, write() returns false. The producer should stop reading until the 'drain' event fires. pipe() and pipeline() handle this automatically — they listen to the 'drain' event and call readable.resume() when the writable is ready. When implementing manual stream processing, always check write() return and handle 'drain'.
Q: When would you use worker_threads vs cluster?
cluster: scale an HTTP server across all CPU cores. Each worker is an independent Node.js process handling incoming connections — the OS distributes TCP connections round-robin. Shared nothing: workers don't share memory and communicate via IPC messages. Best for: multiplying I/O throughput, not for CPU work. worker_threads: CPU-bound parallelism within one process. Workers share the same V8 heap via SharedArrayBuffer and can coordinate with Atomics. Lower overhead than spawning processes. Best for: parallel computation where workers need to share large data structures. Rule of thumb: cluster for scaling request handling, worker_threads for parallelizing computation.
Learning Resources
- Node.js Docs — Streams
- Node.js Docs — Worker Threads
- Node.js Docs — Cluster
- libuv Design Overview
- Node.js Design Patterns — Casciaro & Mammino — enterprise Node.js patterns