How does Node load your modules, and how would you make a Node service use more than one core?
ESM resolves asynchronously and exposes live read-only bindings, CommonJS resolves synchronously and returns a mutable object, and interop leaks in both directions. Extra cores come from worker threads for CPU work, cluster or replicas for connections, child processes for other programs.
What the interviewer is scoring
- Does the candidate explain the synchronous-versus-asynchronous split as the cause of the interop pain, rather than listing syntax differences
- Whether the dual-package hazard is described from experience — two copies of one module, duplicated state, failing instanceof checks
- That they distinguish parallelising JavaScript execution from parallelising connection handling, and pick the mechanism that matches
- Whether they mention that libuv already runs file and crypto work off the main thread, so a worker pool is not always the fix
- Does the candidate raise process supervision, shutdown and memory limits when reaching for cluster, or stop at "it uses all the cores"
Answer
Two module systems, and why there are two
CommonJS is Node's original system. require() is an ordinary synchronous
function call: it resolves a specifier by walking node_modules upwards, reads the
file, wraps it in a function, executes it immediately, and returns whatever
module.exports points at when execution finished. Because it is a function call,
you can call it conditionally, inside a branch, with a computed path, and you can
reach into require.cache afterwards and swap what a later caller receives.
ES modules are a language feature rather than a function. import declarations are
static and hoisted, so the whole graph is resolved and instantiated before any
module body runs, and the loading itself is asynchronous. What you get from an
import is not a copy of a value but a live binding to the exporting module's
variable, and that binding is read-only from the importer's side. Top-level await
is legal, because module evaluation is already asynchronous. Specifiers are URLs, so
extensions are mandatory, directory imports do not resolve to index.js, and
__dirname and __filename do not exist — import.meta.url replaces them, with
import.meta.dirname and import.meta.filename available since Node 20.11.
Which one a .js file is depends on the nearest package.json: "type": "module"
makes it ESM, its absence or "type": "commonjs" makes it CommonJS. The .mjs and
.cjs extensions override that per file.
Where interop leaks
The asymmetry is the whole problem. A synchronous mechanism cannot generally wait for an asynchronous one.
Importing CommonJS from ESM mostly works. The module's module.exports object
arrives as the default export. Named imports also work, but only because Node
statically scans the CommonJS source for assignment patterns it recognises — so
exports built in a loop, assigned through a computed key, or attached by a helper
function are invisible, and you get a syntax-time error naming an export that
demonstrably exists at runtime. The fix is to import the default and destructure it
afterwards.
Going the other way was a hard error for years: require() of an ES module threw
ERR_REQUIRE_ESM, and the standard workaround was a dynamic import(), which
turns a synchronous function into an asynchronous one and propagates up your entire
call stack. Node 22.12 made require() of an ES module work, which removed most of
the pain, but not all of it — if anything in that module's graph uses top-level
await it cannot be evaluated synchronously and the require still fails.
The subtler leak is tooling. Mocking a CommonJS dependency is trivial because the
cache is a mutable object; mocking an ESM dependency is not, because the binding is
read-only by design and there is nothing to overwrite. That is why module mocking
in ESM requires loader hooks registered through module.register(), and why test
runners have had a bumpy time following the ecosystem across.
The hazard that actually bites: one module loaded twice
A package that ships both builds — CommonJS through the require condition and ESM
through the import condition in its exports field — can be loaded twice in one
process, once by each system. Node treats them as unrelated modules with separate
caches, so you get two copies of every top-level variable.
// app.js — ESM, so this resolves through the "import" condition.
import { pool } from "db-client";
export function activeCount() {
return pool.totalCount;
}
// legacy-reporter.cjs — resolves through the "require" condition, which is a
// DIFFERENT module instance with its own top-level `pool`.
const { pool } = require("db-client");
module.exports.activeCount = () => pool.totalCount;
Now there are two connection pools, two registries, two sets of listeners. Symptoms
are indirect and awful: a singleton that initialises twice, a cache that misses
constantly, an instanceof check that fails against a class that is visibly the
right class, an error subclass that no catch block recognises. This is the answer
that separates someone who has published or debugged a dual-format package from
someone who has read about ESM. The mitigation is to keep all state in one format —
typically an ESM implementation with a thin CommonJS wrapper, rather than two
independently compiled trees.
Worker threads, cluster and child processes
All three give you more than one core; they differ in what they isolate and what sharing costs.
| Mechanism | What it creates | Sharing data | Reach for it when |
|---|---|---|---|
worker_threads | A thread with its own V8 isolate, heap and event loop | Structured-clone messages, transferable ArrayBuffers, genuinely shared SharedArrayBuffer | JavaScript-executing CPU work — parsing, hashing, template rendering, compression in JS |
cluster | A full copy of your process, forked, with the primary distributing incoming connections | Nothing shared; IPC messages only | Saturating cores with connection handling in one container, plus crash isolation per worker |
child_process | Any program, or another Node script with an IPC channel | Streams over stdio, or fork() messages | Running something that is not this program — ffmpeg, a Python script, a native binary — or containing something you do not trust |
Worker threads are the right answer for the classic Node weakness. A worker has its
own isolate, so its computation cannot block your request loop, and messages
between them are copied rather than shared unless you opt into SharedArrayBuffer.
import { Worker } from "node:worker_threads";
// Long-lived pools beat per-task workers: spinning up an isolate is not free,
// and a worker created per request will cost more than the work it does.
function runTask(payload) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./hash-worker.js", import.meta.url), {
workerData: payload,
});
worker.once("message", resolve);
worker.once("error", reject);
});
}
cluster forks the process and lets the primary hand out connections, round-robin
by default on Unix-like platforms. It multiplies your capacity to handle concurrent
I/O and it contains crashes, but it does nothing for a single slow computation:
whichever worker gets that request is blocked for its duration, exactly as before.
It also duplicates memory, which matters when your container has a fixed limit and
each process brings its own heap.
child_process covers the case where the work is not JavaScript at all. spawn
streams a subprocess's output, which is what you want for anything large; exec
runs through a shell and buffers the whole output into memory, so it is the wrong
choice for a big result and a command-injection risk with untrusted input; fork
launches another Node script with a message channel.
Choosing, and the mistake that costs the answer
The mistake is answering the wrong question. "How do I use more cores?" almost
always turns out to be either "one operation blocks my loop" or "I cannot handle
enough concurrent requests", and those have different answers. Blocking wants a
worker or an external service. Throughput on I/O wants more processes — and in a
containerised deployment, more replicas rather than cluster, because the
orchestrator already does health checking, restarts and rolling deployment, and
each replica gets its own memory budget instead of sharing one.
Before either, check whether the work is already off the main thread. Node's
asynchronous file, DNS, zlib and some crypto calls run on libuv's thread pool —
four threads by default, resizable through UV_THREADPOOL_SIZE — so a service that
looks CPU-bound may only need a larger pool, or may simply be calling the
synchronous variant of an API that has an asynchronous one. Adding a worker pool to
work that libuv was already parallelising buys you serialisation overhead and
nothing else.
The module systems differ in one thing that causes everything else:
requireis synchronous andimportis not. The concurrency mechanisms differ in one thing too: whether you need to parallelise computation or connections. Answer that question first and the choice makes itself.
Likely follow-ups
- Your library needs to ship both a CommonJS and an ESM build. How do you avoid shipping two copies of its internal state?
- You add a worker pool and throughput gets worse. What are the likely reasons?
- Why is running four replicas under an orchestrator usually preferable to running cluster with four workers inside one container?
- How do you hand an incoming socket to a worker process without proxying its bytes through the primary?
Related questions
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on worker-threads4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you test a Node service, and what do you refuse to mock?mediumSame kind of round: concept5 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardSame kind of round: concept5 min
- How do you turn what the business tells you into a domain model that holds up once the exceptions arrive?hardSame kind of round: design7 min
- Walk me through what happens between a customer tapping a card and the merchant getting paid.hardSame kind of round: concept6 min
- How do you choose between a document store, a key-value store, a wide-column store and a graph database for a new service?hardSame kind of round: design5 min