Globals, events & CPU concurrency
process/global/Buffer, the EventEmitter pattern, and picking worker threads vs child processes vs cluster for CPU-bound work.
Node gives you a handful of always-available globals, an event-emitting pattern that underpins much of its own API, and — when one thread genuinely isn’t enough — three different ways to use more cores. The trick is picking the right one of those three.
Global objects
A few objects are available in every module without importing anything:
process— your handle on the running program.process.argvis the array of CLI arguments (process.argv[2]is the first real arg);process.envexposes environment variables;process.exit(code)ends the process;process.on('SIGTERM', …)lets you handle graceful shutdown.global— the global namespace object (the Node analogue of the browser’swindow). Use it sparingly; assigning to it is a common source of leaks.Buffer— the binary container covered in the streams chapter, available globally.
// Read config from the environment, with a sane default
const PORT = process.env.PORT ?? 3000;
// Graceful shutdown: stop accepting connections, then exit
process.on("SIGTERM", async () => {
await server.close();
process.exit(0);
});
EventEmitter — the pattern under everything
EventEmitter is Node’s built-in publish/subscribe mechanism, and a huge amount of the standard
library is built on it: streams, HTTP servers, sockets, and process itself are all emitters. The
API is two methods — .on() to subscribe, .emit() to publish:
const { EventEmitter } = require("node:events");
class Job extends EventEmitter {
run() {
this.emit("start");
// ...do work...
this.emit("done", { rows: 42 });
}
}
const job = new Job();
job.on("start", () => console.log("started"));
job.on("done", (result) => console.log("finished", result.rows));
job.run();
When one thread isn’t enough: three tools
The event loop makes Node excellent at I/O-bound concurrency on a single thread. It does nothing for CPU-bound work — a heavy computation still blocks that one thread (Chapter 4.1). Node offers three escapes, and choosing wrongly is a classic mistake.
| Tool | What it is | Memory model | Reach for it when |
|---|---|---|---|
| Worker threads | extra threads in the same process, each its own V8 isolate | isolated heaps; can share bytes via SharedArrayBuffer; message-passing | CPU-bound work in JS: image resize, parsing, hashing, heavy math |
| Child processes | separate OS processes you spawn (spawn/fork/exec) | fully isolated; communicate over pipes/IPC | running an external program or shell command, or wanting crash isolation |
| Cluster | multiple Node processes forked from one, sharing a listening port | fully isolated; the OS load-balances connections | scaling an I/O-bound server across all cores |
The decision in plain terms:
- Worker threads keep work inside your process but off the main thread. They’re the right tool
for CPU-bound JavaScript — resizing images, parsing a giant file, cryptographic hashing — because
they share memory cheaply (via
SharedArrayBuffer) and avoid process-spawn overhead. - Child processes are full OS processes with full isolation. Use them to run an external
program (
ffmpeg, a Python script, a shell command) or when you want a crash in the child to not take down the parent.fork()is the specialization for spawning another Node script with a built-in message channel. - Cluster forks several copies of your whole server, all sharing one listening port; the OS distributes incoming connections across them. This is for I/O-bound servers that want to use every CPU core — note it does not help a single slow request, because each request still runs on one thread. In production this is often delegated to a process manager (PM2) or the container orchestrator (one process per container, scaled horizontally).
// main.js — keep the event loop free while a hash crunches
const { Worker } = require("node:worker_threads");
function hashInWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./hash-worker.js", { workerData: data });
worker.once("message", resolve); // result comes back as a message
worker.once("error", reject);
});
}
// hash-worker.js — runs on its OWN thread; blocking here is fine
const { workerData, parentPort } = require("node:worker_threads");
const { createHash } = require("node:crypto");
const digest = createHash("sha256").update(workerData).digest("hex");
parentPort.postMessage(digest); // main thread never blockedThe expensive hash runs on a background thread; the main event loop keeps accepting requests throughout. That’s the entire point — move CPU off the one thread that serves everyone.
const cluster = require("node:cluster");
const http = require("node:http");
const { availableParallelism } = require("node:os");
if (cluster.isPrimary) {
// Primary: fork one worker per core, all sharing port 3000
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on("exit", (worker) => {
console.log(`worker ${worker.process.pid} died — restarting`);
cluster.fork(); // self-heal: replace a dead worker
});
} else {
// Each worker is a full Node process running the same server
http.createServer((req, res) => res.end(`handled by ${process.pid}`)).listen(3000);
}The primary process forks N workers; each one calls listen(3000), but the OS lets them all share
the single port and round-robins incoming connections across them. Hit the server repeatedly and you
will see different PIDs answer. Note what this does and doesn’t buy you: it spreads many requests
across cores (great for an I/O-bound server), but a single CPU-heavy request still blocks the one
worker handling it — for that you still need a worker thread.
| child_process API | Use it for | Returns |
|---|---|---|
spawn(cmd, args) | long-running or large-output programs (stream stdout) | a streaming ChildProcess |
exec(cmdString) | short shell commands where you want the whole output buffered | buffered stdout/stderr (risk: shell injection, buffer cap) |
execFile(file, args) | running a binary directly, no shell | buffered output, safer than exec |
fork(modulePath) | spawning another Node script with a message channel | a ChildProcess with built-in IPC (.send/'message') |
01 Learning objectives
0 / 3 done02 Curated reading
03 Knowledge check
- 01medium
cluster is mainly used to:
- 02hard
CPU-heavy image processing that must not block the HTTP server, sharing memory with the main process, calls for:
04 Interview questions
browse all ↗What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.
-
Worker threads vs child processes vs cluster — what does each give you, and when do you pick which?
All add parallelism, but for different jobs:
- Worker threads — multiple JS threads in one process, can share memory via
SharedArrayBuffer, cheap to spawn, message-passing for the rest. Pick for CPU-bound JS work (image resize, parsing, hashing) you want to keep in-process.
- Child processes (spawn/fork/exec) — full separate OS processes, total isolation, can run any program (not just Node). Pick to run an external binary (ffmpeg, git) or to isolate untrusted/risky work.
- Cluster — forks multiple copies of your server that share one listening port; the OS load-balances connections across them. Pick to use all CPU cores for an I/O-bound server (the classic way to scale an HTTP server).Shorthand: CPU-bound in-process → worker; external program / isolation → child process; scale a server across cores → cluster.
Follow-ups they push on- Why is cluster the wrong tool for a single CPU-heavy computation?
- How do worker threads share data without copying it?
Red flag Reaching for cluster to speed up one CPU-bound task — cluster scales request throughput, not a single computation.
source: Node.js docs — Worker threads ↗ -
Explain the EventEmitter pattern. What's special about the 'error' event, and what's the 'newListener' / max-listeners warning about?
EventEmitteris Node's pub/sub primitive: register handlers withon(event, fn)(oronce) and fire them withemit(event, ...args). Synchronous by default — listeners run in registration order on the same tick. Much of Node's API (streams, HTTP servers, sockets) is built on it.Two gotchas interviewers probe:
-
'error'is special: if youemit("error")and there is no error listener, the emitter throws and crashes the process. Always handle'error'.
- Max listeners: adding more than 10 listeners for one event logs a *MaxListenersExceededWarning* — a heuristic for a listener leak (e.g. adding a handler per request and never removing it). Raise the limit withsetMaxListenersonly if it is genuinely intentional.Follow-ups they push on- Why does an unhandled 'error' event crash, while other unhandled events are silent?
- What real bug does the 'more than 10 listeners' warning usually indicate?
Red flag Treating the max-listeners warning as noise and bumping the limit, instead of finding the leak.
source: GreatFrontend — JS interview questions by ex-interviewers ↗ -
What's on the `process` global that you actually use? Cover argv, env, exit codes, and the on() events.
processis the interface to the running Node process:-
process.argv— CLI arguments array;[0]is the node binary,[1]is the script, real args from[2].
-process.env— environment variables (always strings); the standard place for config/secrets (process.env.NODE_ENV,DATABASE_URL).
-process.exit(code)— terminate now with an exit code (0success, non-zero failure). Prefer letting the loop drain naturally;exit()can cut off in-flight I/O.
- Events:process.on("SIGTERM"/"SIGINT", ...)for graceful shutdown, plus"uncaughtException"and"unhandledRejection"as last-resort handlers.It is also an
EventEmitter, which is why thoseon(...)hooks exist.Follow-ups they push on- Why does process.argv start your real arguments at index 2?
- How do you implement graceful shutdown on SIGTERM in a containerized service?
Red flag Calling process.exit() in the middle of request handling and truncating pending writes/logs.
source: Node.js docs — process ↗ -
A worker thread is meant to share a big array with the main thread to avoid copying. How do you actually share memory, and what's the catch?
Ordinary
postMessage(data)copies via structured clone (or *transfers* anArrayBuffer, leaving the sender's copy detached). To truly share memory you use aSharedArrayBuffer(often viewed through a typed array): both threads see the same bytes, no copy.The catch: shared mutable memory reintroduces data races. Two threads writing the same slot need coordination — use the
AtomicsAPI (Atomics.add,Atomics.wait/notify) for safe reads/writes and signaling. You can only share raw binary buffers this way, not arbitrary JS objects.So: copy is the safe default;
SharedArrayBuffer + Atomicsis the zero-copy path you reach for only when the data is large and the synchronization is worth it.Follow-ups they push on- What's the difference between transferring an ArrayBuffer and sharing a SharedArrayBuffer?
- Why do you need Atomics rather than just writing to the shared buffer directly?
Red flag Assuming postMessage shares memory — by default it copies (or transfers), and SharedArrayBuffer still needs Atomics for safety.
source: Node.js docs — Worker threads ↗ -
Cluster forks one worker per core, but in-memory session state and a request counter behave oddly across requests. Why, and how do you fix it?
Each cluster worker is a separate process with its own memory — they share the listening socket, not application state. A request lands on whichever worker the OS hands it to, so an in-process counter or in-memory session is only correct on the worker that happened to handle the *previous* request. Across workers you see stale/jumping values.
The fix is to externalize shared state: put sessions and counters in Redis (or a DB), so all workers read/write one source of truth. As a stopgap you can enable sticky sessions (route a client to the same worker), but that just pins the problem rather than solving shared state — and it breaks if that worker restarts.
General rule: cluster (and any horizontally-scaled service) must be stateless; keep state in a shared store.
Follow-ups they push on- Why don't cluster workers share a single counter variable?
- What do sticky sessions buy you, and why aren't they a real substitute for external state?
Red flag Keeping sessions/counters in process memory under cluster and expecting consistency across workers.
source: Node.js docs — Cluster ↗ -
Why should you read configuration from environment variables (process.env) instead of hardcoding it or committing a config file?
It is the twelve-factor practice: keep config in the environment, separate from code. Benefits:
- One build, many environments — the same artifact runs in dev/staging/prod by swapping env vars; no code change or rebuild per environment.
- Secrets stay out of git — DB passwords and API keys never land in the repo (a top cause of credential leaks).
- Ops-friendly — platforms (Docker, Kubernetes, Cloudflare, CI) all inject env vars natively.Practical notes:
process.envvalues are always strings (coerce numbers/booleans yourself), use a.envfile (gitignored) locally, and validate required vars at startup so a missingDATABASE_URLfails fast rather than at 3am.Follow-ups they push on- Why validate env vars at boot instead of where they're used?
- What type are process.env values, and what bug does that cause with `process.env.PORT`?
Red flag Committing secrets in a config file, or assuming process.env.PORT is a number (it's a string).
source: Node.js docs — process.env ↗ -
What prints? const EventEmitter = require("events"); const e = new EventEmitter(); e.on("x", () => console.log("A")); e.on("x", () => console.log("B")); console.log("before"); e.emit("x"); console.log("after")
before A B after.EventEmitter listeners are synchronous —
emitcalls each registered handler in order, on the same tick, beforeemitreturns. Soconsole.log("before")runs, thenemit("x")invokes the two listeners immediately (A, thenB, in registration order), and only then doesconsole.log("after")run.This surprises people who assume events are deferred/async like DOM events or
setTimeout. If you need a listener to yield, you must defer it yourself (e.g.setImmediateinside the handler).Follow-ups they push on- How would you make a listener run asynchronously without blocking emit?
- In what order do multiple listeners for the same event fire?
Red flag Assuming emit is asynchronous and printing `before after A B`.
source: Node.js docs — EventEmitter emit ↗ -
What's the difference between EventEmitter's on() and once(), and why is a per-request on() handler a classic leak?
on(event, fn)registers a handler that fires on every emission until you remove it.once(event, fn)fires exactly once and then auto-removes itself.The leak: code that does
emitter.on("data", handler)per request (or per connection) on a long-lived emitter, without ever callingremoveListener/off. Each request adds another handler that's never cleaned up; the array of listeners grows unbounded, the closures pin everything they captured, and memory climbs. Node's heuristic warns at >10 listeners (MaxListenersExceededWarning) precisely to catch this.Fixes: use
oncewhen you only need the next event; remove handlers when the request ends (off); or useAbortSignal/{ signal }to auto-detach. The warning is a symptom — find and remove the accumulating listener, don't just raisesetMaxListeners.What a strong answer coversonfires on every emission until removed;oncefires once and auto-removes.Per-request
on()on a long-lived emitter without cleanup accumulates listeners → leak.Captured closures keep referenced objects alive; >10 listeners triggers the warning.
Fix with
once, explicitoff, or an AbortSignal — not by bumping setMaxListeners.
Quick self-checkWhich best describes the difference between on() and once()?
-
Wrong: once() fires the handler a single time, period.
-
Correct — that auto-removal is exactly why once() avoids the accumulation leak.
-
Wrong: both invoke handlers synchronously during emit.
-
Wrong: the auto-removal after one call is a real behavioral difference.
Follow-ups they push on- How does passing an AbortSignal help auto-remove a listener?
- Why does the leak grow memory and not just listener count?
Red flag Adding a listener per request and never removing it, then silencing the max-listeners warning instead of fixing it.
source: Node.js docs — emitter.once() ↗ -
spawn vs exec vs execFile vs fork in child_process — what distinguishes them, and which can blow up on large output?
All run a child process, differently:
-
spawn(cmd, args)— launches a process and streams its stdout/stderr. No output-size limit; use it for long-running processes or large output (e.g. piping ffmpeg).
-exec(cmdString)— runs the command in a shell and buffers all output, handing it to a callback. Convenient, but the buffer is capped (maxBuffer, default 1 MB) — exceed it and the child is killed with an error. Shell parsing also opens command-injection risk if you interpolate untrusted input.
-execFile(file, args)— likeexec(buffers output) but runs the binary directly, no shell — safer against injection, no shell features.
-fork(modulePath)— a specializedspawnfor a new Node.js process running a JS file, with a built-in IPC channel (child.send/process.on("message")).The trap:
exec/execFilebuffer, so big output OOMs or tripsmaxBuffer; stream withspawninstead.What a strong answer coversspawnstreams output (no size cap) — best for large/long output.execruns in a shell and buffers (default 1 MB maxBuffer) → kills child on overflow; injection risk.execFilebuffers too but skips the shell — safer, no shell features.forkspawns a child Node process with an IPCmessagechannel.
Follow-ups they push on- Why is execFile safer than exec against command injection?
- What error do you get when exec output exceeds maxBuffer?
Red flag Using exec for a command with large output — it buffers and either OOMs or hits maxBuffer; stream with spawn.
source: Node.js docs — Child process ↗ -
How do worker threads communicate with the main thread, and what data can/can't cross the boundary?
Worker threads talk over a message channel:
worker.postMessage(value)/parentPort.postMessage(value), received via"message"events. There is no shared scope — each thread has its own V8 isolate, globals, and module registry.What can cross:
- Structured-cloneable values — objects, arrays, Maps, Sets, typed arrays, etc. are copied (deep clone).
- Transferable objects (ArrayBuffer,MessagePort) can be moved in the transferList: ownership transfers and the sender's copy is detached (zero-copy, but no longer usable on the sender).
-SharedArrayBufferis genuinely shared (both threads see the same bytes; coordinate withAtomics).What can't cross: functions, closures, class instances with methods, DOM-like handles — anything not structured-cloneable throws a
DataCloneError. So you pass data, not behavior; the worker loads its own code from a file/string.What a strong answer coversCommunicate via
postMessage+'message'events; no shared scope between threads.Plain data is deep-copied via structured clone.
ArrayBuffer/MessagePort can be transferred (detached on sender, zero-copy).
Functions/closures/methods can't be sent (DataCloneError); SharedArrayBuffer truly shares memory.
Follow-ups they push on- What's the difference between transferring an ArrayBuffer and copying it?
- Why can't you postMessage a function to a worker?
Red flag Trying to postMessage a function or class instance with methods — only structured-cloneable data crosses.
source: Node.js docs — worker.postMessage() ↗ -
How do you implement graceful shutdown on SIGTERM in a containerized Node service, and why does it matter?
When an orchestrator (Kubernetes, Docker, a process manager) stops your container, it sends
SIGTERMand gives a grace period beforeSIGKILL. Without handling it, in-flight requests are cut off, connections drop, and writes can be left half-done.Graceful shutdown on
SIGTERM:``
`
process.on("SIGTERM", async () => {
server.close(); // stop accepting new connections
await drainInFlightRequests(); // let current ones finish
await db.end(); await redis.quit(); // close pools/connections
process.exit(0);
});Steps: stop accepting new work (server.close()
), let in-flight requests drain (with a timeout fallback so a stuck request can't hang shutdown forever), close DB/cache/queue connections, then exit0. This avoids dropped requests during deploys/scaling and prevents connection-pool leaks. Also handleSIGINT` for local Ctrl-C.What a strong answer coversOrchestrators send SIGTERM, then SIGKILL after a grace period.
On SIGTERM: stop accepting new connections (
server.close), drain in-flight, close pools, exit 0.Add a timeout fallback so a stuck request can't block shutdown indefinitely.
Prevents dropped requests during deploys and connection-pool leaks; handle SIGINT too.
Follow-ups they push on- Why do you need a timeout fallback around draining in-flight requests?
- What happens to open requests if you ignore SIGTERM until SIGKILL?
Red flag Calling process.exit(0) immediately on SIGTERM, truncating in-flight requests instead of draining first.
source: Node.js docs — Signal events ↗ -
What prints, and is it on the main thread? const { Worker, isMainThread } = require("worker_threads"); if (isMainThread) { new Worker(__filename); console.log("main"); } else { console.log("worker"); }
Both
mainandworkerprint —mainfrom the main thread,workerfrom the spawned worker — and the relative order is non-deterministic (the worker starts asynchronously, somainusually prints first, but don't rely on it).The pattern is the standard self-referencing worker: the file checks
isMainThread. On first run it'strue, so the branch spawns aWorker(__filename)— which re-executes the same file in a new thread whereisMainThreadisfalse, taking theelsebranch and printingworker. The worker has its own module instance, globals, and event loop; it does not share memory with the main thread (only message-passing / SharedArrayBuffer).What a strong answer coversBoth branches run:
mainon the main thread,workerin the spawned thread.new Worker(__filename)re-executes the file withisMainThread === false.Relative print order is non-deterministic (worker starts async).
The worker has its own isolate/globals/event loop — no shared memory by default.
Quick self-checkWhat does this program output?
-
Wrong: the new Worker re-runs the file and prints 'worker' too.
-
Correct — the file runs twice (main + worker thread) and the worker starts asynchronously.
-
Wrong: the worker starts async, so 'main' typically prints first; order isn't guaranteed either way.
-
Wrong: self-referencing Worker(__filename) is the standard pattern and works.
Follow-ups they push on- Why is the order of 'main' vs 'worker' not guaranteed?
- How would the worker send a result back to the main thread?
Red flag Assuming only one line prints, or that the worker shares the main thread's variables/globals.
source: Node.js docs — worker_threads isMainThread ↗ -
Why does cluster scale an I/O-bound server but not a single CPU-bound computation, and how does it share a port?
Cluster forks N worker processes (typically one per core), each a full Node instance with its own event loop. They all share one listening socket: the primary process creates the listener and hands incoming connections to workers (by default the OS/round-robin distributes them). So N independent event loops handle requests in parallel — that's why it scales an I/O-bound server across cores: more loops = more concurrent request handling and CPU utilization.
It does nothing for a single CPU-bound computation, because that one task runs on one worker's single thread; the other workers can't help compute it — they're separate processes handling *other* requests. Cluster scales throughput (requests/sec across many requests), not the latency of one heavy computation. For that, you need worker threads (split the work) or an algorithmic fix.
What a strong answer coversCluster = N processes, each its own event loop, sharing one listening socket.
Primary distributes connections (round-robin by default) → parallel request handling across cores.
Scales I/O-bound throughput, not the latency of a single computation.
One CPU-bound task still runs on one thread; use worker threads to split it.
Follow-ups they push on- How does the primary process distribute incoming connections to workers?
- When would worker threads beat cluster for the same workload?
Red flag Expecting cluster to speed up one heavy computation — it multiplies request handlers, not the single task.
source: Node.js docs — Cluster ↗ -
What globals are available in Node without require (e.g. globalThis, Buffer, __dirname, setTimeout), and which are NOT truly global?
Genuinely global (available anywhere, no import):
globalThis,process,Buffer,console, the timer functions (setTimeout/setInterval/setImmediateand theirclear*),queueMicrotask,URL/URLSearchParams,TextEncoder/TextDecoder, and (in modern Node)fetch,structuredClone, andAbortController.The trap — these look global but are actually module-scoped variables injected by the CommonJS wrapper, not properties of
globalThis:__dirname,__filename,require,module,exports. That's exactly why they don't exist in ES modules (no wrapper) — you useimport.meta.urland staticimportinstead.So: timers/process/Buffer are true globals; the
require/module/__dirnamefamily are per-module wrapper locals.What a strong answer coversTrue globals:
globalThis,process,Buffer,console, timers,fetch,URL,AbortController, etc.__dirname,__filename,require,module,exportsare module-wrapper locals, not on globalThis.That's why those CJS locals are absent in ESM (no module wrapper).
Many former-polyfill APIs (fetch, structuredClone) are now built-in globals.
Follow-ups they push on- Why are __dirname and require unavailable in ES modules?
- Is `fetch` available globally in current Node without a library?
Red flag Calling __dirname/require 'global' — they're injected per-module by the CJS wrapper and absent in ESM.
source: Node.js docs — Global objects ↗