Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

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.

6 min readUpdated 2026-07-27

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.

MechanismWhat it createsSharing dataReach for it when
worker_threadsA thread with its own V8 isolate, heap and event loopStructured-clone messages, transferable ArrayBuffers, genuinely shared SharedArrayBufferJavaScript-executing CPU work — parsing, hashing, template rendering, compression in JS
clusterA full copy of your process, forked, with the primary distributing incoming connectionsNothing shared; IPC messages onlySaturating cores with connection handling in one container, plus crash isolation per worker
child_processAny program, or another Node script with an IPC channelStreams over stdio, or fork() messagesRunning 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: require is synchronous and import is 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

Further reading

modulesesmcommonjsworker-threadscluster