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.
import { pool } from "db-client";
export function activeCount() {
return pool.totalCount;
}
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.
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.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).