> cs·fundamentals
interview 0% 22m read
4.5 [J][A] 14 interview Q's

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.argv is the array of CLI arguments (process.argv[2] is the first real arg); process.env exposes 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’s window). 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.

ToolWhat it isMemory modelReach for it when
Worker threadsextra threads in the same process, each its own V8 isolateisolated heaps; can share bytes via SharedArrayBuffer; message-passingCPU-bound work in JS: image resize, parsing, hashing, heavy math
Child processesseparate OS processes you spawn (spawn/fork/exec)fully isolated; communicate over pipes/IPCrunning an external program or shell command, or wanting crash isolation
Clustermultiple Node processes forked from one, sharing a listening portfully isolated; the OS load-balances connectionsscaling an I/O-bound server across all cores
CPU work in JS → worker threads. Run a program → child process. Scale a web server → cluster.

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).
worker threads — one processmainworker 1own isolateworker 2own isolatepostMessageSharedArrayBuffer (shared bytes)cluster — N processes:3000 portproc Aisolatedproc Bisolatedproc CisolatedOS load-balances connections · no shared memory
FIG 1 · workers vs cluster Two different shapes of parallelism. LEFT: worker threads live inside ONE process — one main thread plus N workers, each its own V8 isolate, communicating by messages and optionally sharing raw bytes through a SharedArrayBuffer. Use them for CPU-bound JS. RIGHT: cluster forks N separate Node PROCESSES that share one listening socket; the OS hands each incoming connection to one of them. Fully isolated, no shared memory. Use it to spread an I/O-bound server across cores.
Offloading CPU work to a worker thread
// 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 blocked

The 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.

Scaling an I/O-bound server with cluster
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 APIUse it forReturns
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 bufferedbuffered stdout/stderr (risk: shell injection, buffer cap)
execFile(file, args)running a binary directly, no shellbuffered output, safer than exec
fork(modulePath)spawning another Node script with a message channela ChildProcess with built-in IPC (.send/'message')
spawn streams; exec buffers (and uses a shell); fork is the Node-to-Node specialization with IPC.

01 Learning objectives

0 / 3 done

02 Curated reading

03 Knowledge check

knowledge check2 questions · pass ≥ 70%
  1. 01medium

    cluster is mainly used to:

  2. 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.

  • Commonly asked senior design very common 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.

    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 ↗
  • AmazonMetaTikTok mid concept common Explain the EventEmitter pattern. What's special about the 'error' event, and what's the 'newListener' / max-listeners warning about?

    EventEmitter is Node's pub/sub primitive: register handlers with on(event, fn) (or once) and fire them with emit(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 you emit("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 with setMaxListeners only if it is genuinely intentional.

    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 ↗
  • Commonly asked junior concept common What's on the `process` global that you actually use? Cover argv, env, exit codes, and the on() events.

    process is 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 (0 success, 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 those on(...) hooks exist.

    Red flag Calling process.exit() in the middle of request handling and truncating pending writes/logs.

    source: Node.js docs — process ↗
  • Commonly asked senior concept occasional 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* an ArrayBuffer, leaving the sender's copy detached). To truly share memory you use a SharedArrayBuffer (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 Atomics API (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 + Atomics is the zero-copy path you reach for only when the data is large and the synchronization is worth it.

    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 ↗
  • Commonly asked senior debug occasional 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.

    Red flag Keeping sessions/counters in process memory under cluster and expecting consistency across workers.

    source: Node.js docs — Cluster ↗
  • Commonly asked junior concept occasional 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.env values are always strings (coerce numbers/booleans yourself), use a .env file (gitignored) locally, and validate required vars at startup so a missing DATABASE_URL fails fast rather than at 3am.

    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 ↗
  • Commonly asked mid debug occasional 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 synchronousemit calls each registered handler in order, on the same tick, before emit returns. So console.log("before") runs, then emit("x") invokes the two listeners immediately (A, then B, in registration order), and only then does console.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. setImmediate inside the handler).

    Red flag Assuming emit is asynchronous and printing `before after A B`.

    source: Node.js docs — EventEmitter emit ↗
  • Commonly asked mid concept common 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 calling removeListener/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 once when you only need the next event; remove handlers when the request ends (off); or use AbortSignal/{ signal } to auto-detach. The warning is a symptom — find and remove the accumulating listener, don't just raise setMaxListeners.

    What a strong answer covers
    • on fires on every emission until removed; once fires 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, explicit off, or an AbortSignal — not by bumping setMaxListeners.

    Quick self-check

    Which best describes the difference between on() and once()?

    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() ↗
  • Commonly asked senior concept occasional 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) — like exec (buffers output) but runs the binary directly, no shell — safer against injection, no shell features.
    - fork(modulePath) — a specialized spawn for a new Node.js process running a JS file, with a built-in IPC channel (child.send/process.on("message")).

    The trap: exec/execFile buffer, so big output OOMs or trips maxBuffer; stream with spawn instead.

    What a strong answer covers
    • spawn streams output (no size cap) — best for large/long output.

    • exec runs in a shell and buffers (default 1 MB maxBuffer) → kills child on overflow; injection risk.

    • execFile buffers too but skips the shell — safer, no shell features.

    • fork spawns a child Node process with an IPC message channel.

    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 ↗
  • Commonly asked senior concept occasional 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).
    - SharedArrayBuffer is genuinely shared (both threads see the same bytes; coordinate with Atomics).

    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 covers
    • Communicate 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.

    Red flag Trying to postMessage a function or class instance with methods — only structured-cloneable data crosses.

    source: Node.js docs — worker.postMessage() ↗
  • Commonly asked mid design common 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 SIGTERM and gives a grace period before SIGKILL. 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 exit 0. This avoids dropped requests during deploys/scaling and prevents connection-pool leaks. Also handle SIGINT` for local Ctrl-C.

    What a strong answer covers
    • Orchestrators 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.

    Red flag Calling process.exit(0) immediately on SIGTERM, truncating in-flight requests instead of draining first.

    source: Node.js docs — Signal events ↗
  • Commonly asked senior debug occasional 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 main and worker print — main from the main thread, worker from the spawned worker — and the relative order is non-deterministic (the worker starts asynchronously, so main usually prints first, but don't rely on it).

    The pattern is the standard self-referencing worker: the file checks isMainThread. On first run it's true, so the branch spawns a Worker(__filename) — which re-executes the same file in a new thread where isMainThread is false, taking the else branch and printing worker. 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 covers
    • Both branches run: main on the main thread, worker in the spawned thread.

    • new Worker(__filename) re-executes the file with isMainThread === 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-check

    What does this program output?

    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 ↗
  • Commonly asked senior concept occasional 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 covers
    • Cluster = 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.

    Red flag Expecting cluster to speed up one heavy computation — it multiplies request handlers, not the single task.

    source: Node.js docs — Cluster ↗
  • Commonly asked mid concept occasional 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/setImmediate and their clear*), queueMicrotask, URL/URLSearchParams, TextEncoder/TextDecoder, and (in modern Node) fetch, structuredClone, and AbortController.

    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 use import.meta.url and static import instead.

    So: timers/process/Buffer are true globals; the require/module/__dirname family are per-module wrapper locals.

    What a strong answer covers
    • True globals: globalThis, process, Buffer, console, timers, fetch, URL, AbortController, etc.

    • __dirname, __filename, require, module, exports are 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.

    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 ↗