Modules & packages
CommonJS vs ES Modules (and the exports vs module.exports gotcha), plus npm/package.json: deps vs devDeps, semver, lockfiles.
Node has two module systems that coexist: the original CommonJS (require) and the standard
ES Modules (import). Most of the day-to-day friction in a Node project — “Cannot use import
statement outside a module”, a mysteriously empty exports, a dependency that broke after a fresh
install — traces back to one of these two topics. Get the model straight once and the error messages
stop being mysterious.
CommonJS vs ES Modules
| CommonJS | ES Modules | |
|---|---|---|
| Import | const x = require('x') | import x from 'x' |
| Export | module.exports = … | export / export default |
| Loading | synchronous, runs on first require | async, statically resolved before execution |
| Timing | dynamic — require anywhere, even conditionally | import hoisted to the top; static (use import() for dynamic) |
| File default | .cjs, or .js with no "type" field | .mjs, or .js under "type": "module" |
| Dir/file path | __dirname, __filename built in | import.meta.dirname / import.meta.url |
What decides which system a .js file uses is the "type" field in the nearest
package.json: absent or "commonjs" means CJS, "module" means ESM. ESM is the standard and the
direction the ecosystem is moving; CommonJS remains everywhere in existing code. Node 24 can load
both, and ESM can import a CommonJS module. As of Node 22+ a CommonJS file can even require() a
synchronous ES module — but if that ESM uses top-level await, require throws and you must reach
for dynamic import() from the CJS side.
How resolution actually finds a file
The two specifier kinds resolve by completely different rules, and the SVG below is worth internalizing — most “module not found” confusion is really confusion about which rule applies.
The exports vs module.exports gotcha
This trips up nearly everyone once. In CommonJS, module.exports is the object that actually gets
returned by require(). exports is just a convenience alias that starts out pointing at the
same object. The catch: reassigning exports breaks the link, because you’ve only repointed the
local variable — module.exports still references the original empty object.
Module caching — same instance, every time
Each module is cached after first load, keyed by its resolved absolute path. Requiring the same module twice returns the same instance — handy for singletons, surprising if you expect a fresh copy. The module body runs exactly once.
// counter.js
let count = 0;
module.exports = {
inc() { return ++count; },
get() { return count; },
};
// a.js
const c1 = require("./counter");
const c2 = require("./counter"); // SAME cached instance, body did not re-run
console.log(c1 === c2); // ?
c1.inc();
c1.inc();
console.log(c2.get()); // ?Output:
true
2require("./counter") runs counter.js once, caches the resulting module.exports, and hands the
identical object back on every subsequent require. So c1 and c2 are the same reference
(true), and the two inc() calls through c1 are visible through c2 (2). This is exactly how
the singleton pattern falls out of CommonJS for free — and exactly why a “config” or “db” module
shared across your app holds one shared state.
npm, package.json, and semver
package.json is the project manifest. The two dependency buckets matter:
dependencies— needed at runtime in production (Express, the database driver).devDependencies— needed only to build or test (TypeScript, ESLint, the test runner).npm install --omit=dev(orNODE_ENV=production) skips these — keep your runtime image lean.
Version ranges use semver prefixes, and the prefix controls how far an install may drift:
| Range | Allows updates to | Means |
|---|---|---|
^1.2.3 | 1.x.x (<2.0.0) | caret — minor + patch; the npm default |
~1.2.3 | 1.2.x (<1.3.0) | tilde — patch only |
1.2.3 | nothing | exact pin |
* / latest | anything | avoid — non-reproducible |
The caret ^ is the npm default (it tracks compatible minor+patch); the tilde ~ is stricter
(patch only). But these are ranges — the manifest says “anything compatible.” What pins the
exact resolved versions is the lockfile.
Two more daily tools: scripts in package.json defines named commands run via npm run NAME
(and test/start get shortcuts); npx runs a package’s binary without a global install,
fetching it on demand — npx create-react-app, npx tsc.
01 Learning objectives
0 / 2 done02 Curated reading
03 Knowledge check
- 01easy
In semver, `^1.2.3` allows updates up to (but not including):
- 02easy
package-lock.json exists to:
- 03medium
In CommonJS, reassigning `exports = {...}` (instead of `module.exports`) is a gotcha because:
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.
-
CommonJS vs ES Modules: name the real differences (syntax, loading, this, __dirname, top-level await).
- Syntax: CJS uses
require()/module.exports; ESM usesimport/export.
- Loading: CJS is synchronous and loads at runtime, sorequire()can be conditional/dynamic. ESM is asynchronous with a static parse phase — imports are hoisted and resolved before the body runs (use dynamicimport()for conditional loading).
- Bindings: CJS exports a *copied value*; ESM exports *live bindings* (re-exported values stay in sync).
-this: top-levelthisismodule.exportsin CJS, butundefinedin ESM.
-__dirname/__filename: available in CJS; in ESM you derive them fromimport.meta.url.
- Top-level await: allowed in ESM, not in CJS.Node picks the mode from
"type"in package.json (or.cjs/.mjsextension).Follow-ups they push on- How do you get __dirname in an ES module?
- Why can you require() conditionally but not top-level-import conditionally?
Red flag Saying they are interchangeable — sync vs async loading and live-bindings vs copied-values cause real behavioral differences.
source: Node.js docs — Modules: ECMAScript modules ↗ -
What's the difference between `exports = foo` and `module.exports = foo` in CommonJS? Which one actually works, and why?
Only
module.exports = fooworks to replace the whole export.At module start, Node does roughly
exports = module.exports = {}—exportsis just a *local variable pointing at the same object* asmodule.exports. What gets returned to the requirer ismodule.exports.-
exports.foo = ...works because you are mutating the shared object.
-exports = fooonly reassigns the local variableexports;module.exportsstill points at the original{}, so the requirer gets an empty object.
-module.exports = foocorrectly replaces what is returned.Rule: use
exports.x = ...to add properties, butmodule.exports = ...to export a single thing.Follow-ups they push on- After `module.exports = foo`, does `exports.bar = 1` still affect the export? (No.)
- Why does `exports.foo = ...` work but `exports = {...}` not?
Red flag Reassigning `exports = ...` and wondering why the importer gets `{}` — you broke the alias to module.exports.
source: Node.js docs — module.exports vs exports ↗ -
package.json: dependencies vs devDependencies vs peerDependencies — what's the distinction and when does each install?
-
dependencies— packages your code needs at runtime (Express, the DB driver). Installed for everyone who installs your package.
-devDependencies— needed only to build/test/lint (TypeScript, jest, eslint). Installed for local dev, but skipped withnpm install --omit=dev(production installs).
-peerDependencies— a package your plugin expects the host project to provide, to avoid duplicate/clashing copies (e.g. a React component library listsreactas a peer so it uses the app's single React).Getting this wrong: a runtime package in devDeps breaks production; a build tool in deps bloats the production image.
Follow-ups they push on- What breaks if you put your web framework in devDependencies?
- Why do React component libraries list react as a peerDependency rather than a dependency?
Red flag Putting runtime libs in devDependencies — works locally, then crashes in a --omit=dev production install.
source: npm docs — package.json dependencies ↗ -
In semver, what versions does "^1.2.3" allow, and how does that differ from "~1.2.3"? When is each dangerous?
Semver is
MAJOR.MINOR.PATCH.-
^1.2.3(caret) allows everything up to but not including the next MAJOR —>=1.2.3 <2.0.0. So1.9.0is fine;2.0.0is not. (Special case: for0.x,^0.2.3is treated as>=0.2.3 <0.3.0— a0.xminor bump can break.)
-~1.2.3(tilde) allows only PATCH bumps —>=1.2.3 <1.3.0.Caret is the npm default. The risk: a sloppy maintainer ships a breaking change in a *minor*, and your caret range silently pulls it in. That is exactly why
package-lock.jsonpins exact resolved versions for reproducible installs.Follow-ups they push on- Why is the lockfile essential even though you specified a range?
- What does ^0.2.3 resolve to, and why is the 0.x rule special?
Red flag Thinking ^ and ~ are the same, or trusting that minor bumps are always non-breaking.
source: npm docs — About semantic versioning ↗ -
What does package-lock.json do, and why should you commit it? What's the difference between `npm install` and `npm ci`?
package-lock.jsonrecords the *exact* version, resolved URL, and integrity hash of every package in the tree (including transitive deps). Becausepackage.jsononly specifies ranges, the lockfile is what makes installs reproducible — everyone and CI get byte-identical trees. Commit it.-
npm installreadspackage.json, may update the lockfile to satisfy ranges, and adds/removes packages. Good for development.
-npm ciinstalls strictly from the lockfile, errors ifpackage.jsonand the lock disagree, and wipesnode_modulesfirst. Deterministic and faster — the right choice for CI and production builds.Follow-ups they push on- Why does npm ci fail if package.json and the lockfile are out of sync?
- What integrity field in the lockfile protects against tampered packages?
Red flag Gitignoring the lockfile (irreproducible builds) or using `npm install` in CI instead of `npm ci`.
source: npm docs — npm ci ↗ -
What prints? // counter.js: let c = 0; module.exports = { inc: () => ++c, get: () => c }; // app.js: const a = require("./counter"); const b = require("./counter"); a.inc(); console.log(b.get())
It prints
1.CommonJS caches modules by resolved path. The first
require("./counter")executes the file once and caches itsmodule.exports; the secondrequirereturns the same cached object — no re-execution. Soaandbare the *same* object sharing the *same*c.a.inc()makesc1, andb.get()reads that samec:1.This is why a module is effectively a singleton — handy for shared config/connections, but a trap if you expect a fresh instance per require.
Follow-ups they push on- What key does the module cache use, and how can the same file be loaded twice?
- How would you force a fresh module instance? (Bust require.cache — and why that's usually a smell.)
Red flag Expecting each require to give a fresh module — it returns the cached singleton.
source: Node.js docs — Modules caching ↗ -
How does Node resolve `require("foo")` (a bare specifier) vs `require("./foo")` (a relative path)?
Relative/absolute (
./foo,../foo,/abs/foo): resolve against the current file. Node tries the exact path, thenfoo.js/foo.json/foo.node, thenfoo/as a directory (itspackage.jsonmain/exports, elseindex.js).Bare specifier (
foo): Node walksnode_modulesoutward —./node_modules/foo, then the parent'snode_modules, up to the filesystem root — and uses the first match. Core modules (fs,path, ornode:fs) short-circuit this and win immediately.This outward walk is why a dependency can resolve a different copy of a package than your app, and why
node_modulescan nest.Follow-ups they push on- Why might two packages each get their own copy of a shared dependency?
- What does the `exports` field in package.json change about resolution?
Red flag Assuming bare specifiers resolve from one global location — Node searches node_modules up the directory tree.
source: Node.js docs — Modules: all-together resolution ↗ -
What is a circular dependency between two CommonJS modules, and what does the importer actually receive?
A circular dependency is
a.jsrequiringb.jswhileb.jsrequiresa.js. CommonJS doesn't deadlock — it returns a partially-completedmodule.exports.When
astarts loading and requiresb,bbegins executing; ifbthen requiresa, Node seesais already in progress and handsbthe **partial exports ofaas they exist *right now* (whateverahad assigned before therequire(b)line). Ifahadn't exported the thingbneeds yet,bseesundefined.So behavior depends on statement order** and is fragile. Symptom: a value is mysteriously
undefinedonly when modules load in a particular order. Fixes: restructure to break the cycle, extract the shared piece into a third module, or require lazily (inside the function that uses it). ESM handles cycles better via live bindings but can still hit temporal-dead-zone errors.What a strong answer coversCJS doesn't deadlock; it returns the partial exports of the in-progress module.
What
bsees ofadepends on whatahad exported before itsrequire(b)line.Symptom: a dependency value is
undefineddepending on load order.Fix: break the cycle, extract a shared module, or require lazily inside a function.
Follow-ups they push on- How does ESM's live-binding model change circular-dependency behavior?
- Why does moving the require() to the bottom of the file sometimes 'fix' it?
Red flag Assuming a circular require throws or deadlocks — it silently returns half-initialized exports.
source: Node.js docs — Modules: Cycles ↗ -
How do you import a CommonJS package from an ES module, and an ESM-only package from CommonJS? Why is one harder?
CJS → from ESM: easy.
import pkg from "cjs-package"works — Node treats the module'smodule.exportsas the default export. Named imports work for statically-detectable named exports, but the whole object is reliably available as the default.ESM-only → from CJS: harder, because
require()of an ESM module is restricted. ESM is asynchronous (it can use top-level await) andrequireis synchronous, so historicallyrequire("esm-only-pkg")threwERR_REQUIRE_ESM. The portable workaround is dynamicimport(), which returns a promise:``
`
const { thing } = await import("esm-only-pkg");(Recent Node versions added synchronous require()
of ESM that has no top-level await, but dynamicimport()is the safe, version-independent answer.)The asymmetry comes from sync-vs-async loading: pulling async ESM into a sync require` is the fundamentally awkward direction.
What a strong answer coversCJS from ESM:
import x from 'cjs'— module.exports becomes the default export.ESM from CJS:
require()is restricted (ESM is async), classicallyERR_REQUIRE_ESM.Portable fix for ESM-from-CJS: dynamic
import()(returns a promise).Asymmetry stems from ESM being async (top-level await) vs require being synchronous.
Follow-ups they push on- Why is dynamic import() the version-safe way to load ESM from CJS?
- What does it mean that newer Node can require() ESM without top-level await?
Red flag Trying to require() an ESM-only package and hitting ERR_REQUIRE_ESM — reach for dynamic import().
source: Node.js docs — Interoperability with CommonJS ↗ -
What does the "exports" field in package.json do, and how do conditional exports (import/require/default) work?
The
exportsfield defines a package's official entry points and, crucially, encapsulates it: once you declareexports, consumers can import only the paths you list — deep imports into internal files (pkg/lib/secret.js) are blocked. It supersedesmain.Conditional exports map one specifier to different files depending on how it's loaded:
``
`
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs",
"default": "./index.mjs"
}
}
}Node picks import
when loaded viaimport/import(),requirewhen loaded viarequire(), anddefaultas the fallback. This is how a package ships both an ESM and a CJS build from one entry point (the "dual package"). Conditions are matched in order, so put more specific ones first;default` must be last.What a strong answer coversexportsdeclares entry points and encapsulates internals (blocks deep imports).Conditional exports map a specifier to different files by condition.
importvsrequirelets one package ship both ESM and CJS builds (dual package).Conditions match in order, most-specific first;
defaultis the last-resort fallback.
Follow-ups they push on- What's the 'dual package hazard' and how do conditional exports relate to it?
- How does the exports field break tools that relied on deep-importing internal files?
Red flag Adding an exports field and accidentally breaking consumers who deep-imported internal paths.
source: Node.js docs — Packages: conditional exports ↗ -
What is a transitive dependency, and why can `npm audit` report dozens of vulnerabilities you didn't install directly?
A transitive (indirect) dependency is a package your dependencies depend on — not something you listed in your
package.json. A modern app with a handful of direct deps routinely pulls in hundreds of transitive packages, and the lockfile records the whole tree.npm auditscans that entire tree against a vulnerability database, so most reported issues live deep in transitive packages you never named. That's also the supply-chain risk surface: you trust not just your deps but everything they trust.Fixing them:
npm audit fixbumps within allowed ranges; a transitive fix may require the direct dependency to update, or anoverridesentry in package.json to force a patched version. And weigh severity in context — a vuln in a dev-only or unreachable code path isn't always exploitable in your app.What a strong answer coversTransitive = a dependency of your dependencies; you didn't list it directly.
Apps pull in hundreds of transitive packages; the lockfile captures the full tree.
npm auditscans the whole tree, so most findings are in indirect packages.Fix via
npm audit fix, upgrading the direct dep, oroverridesto pin a patched version.
Follow-ups they push on- When would you use the `overrides` field to force a transitive version?
- Why isn't every audit 'high severity' finding actually exploitable in your app?
Red flag Treating every npm audit finding as a critical blocker, or assuming you can only fix direct dependencies.
source: npm docs — npm audit ↗ -
What prints? // a.mjs: export let count = 0; export function inc() { count++; } // main.mjs: import { count, inc } from "./a.mjs"; inc(); console.log(count)
It prints
1.ESM exports are live bindings, not copied values. The imported
countis a read-only *view* of the exporter'scountvariable — not a snapshot taken at import time. Wheninc()mutatescountinsidea.mjs, the importer's view reflects the new value, soconsole.log(count)reads1.Contrast with CommonJS:
const { count } = require("./a")copies the value at require time, so callinginc()would not change your localcount(it'd still be0). Note you can *read* the live binding but not reassign it from the importer (count = 5throws — imports are read-only).What a strong answer coversESM imports are live, read-only bindings to the exporter's variables.
Mutating the exported variable inside its module is visible to all importers.
CommonJS copies values at require time, so it would still print 0.
Importers can read the live value but cannot reassign the binding (TypeError).
Quick self-checkWhat does main.mjs print?
-
Wrong: that's the CommonJS copy-by-value behavior; ESM uses live bindings.
-
Correct — ESM imports are live bindings, so inc()'s mutation of count is visible.
-
Wrong: count is exported and initialized to 0, never undefined.
-
Wrong: reading count is fine; only reassigning the import would throw.
Follow-ups they push on- What's the CommonJS equivalent and why does it print 0 instead?
- Why can't you reassign an imported binding in the importing module?
Red flag Assuming ESM imports are value snapshots like CJS — they're live bindings, so mutations show through.
source: MDN — export (live bindings) ↗ -
How do you get __dirname and __filename in an ES module, and why aren't they available like in CommonJS?
In CommonJS,
__dirnameand__filenameare injected into every module's wrapper scope. ESM has no such wrapper — modules run in a standard scope where those magic variables don't exist. Instead, ESM gives youimport.meta.url, the file's URL (afile://string).Derive the paths from it:
``
`
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);fileURLToPath
is required becauseimport.meta.urlis a URL, not a filesystem path (and on Windows or with spaces/special chars, naive string slicing breaks). Recent Node also exposesimport.meta.dirname/import.meta.filename` as conveniences.What a strong answer coversCJS injects
__dirname/__filenamevia the module wrapper; ESM has no wrapper.ESM exposes
import.meta.url(afile://URL) instead.Convert with
fileURLToPath(import.meta.url)thenpath.dirname(...).Don't string-slice the URL —
fileURLToPathhandles Windows/encoding correctly.
Follow-ups they push on- Why is fileURLToPath needed instead of just stripping the file:// prefix?
- What are import.meta.dirname and import.meta.filename?
Red flag Hand-parsing import.meta.url by slicing 'file://' — breaks on Windows paths and URL-encoded characters.
source: Node.js docs — import.meta.url ↗ -
Why does committing node_modules vs relying on the lockfile matter, and what makes `npm ci` deterministic where `npm install` isn't?
You normally don't commit
node_modules(huge, platform-specific native builds, churns the diff); you commit the lockfile and rebuild from it. The lockfile +npm ciis what gives reproducibility without the bloat.What makes them differ:
-
npm installtreatspackage.jsonas the source of truth: it resolves ranges, may update the lockfile, and reuses/patches an existingnode_modules. Two installs at different times can yield different trees if a new in-range version was published.
-npm citreats the lockfile as authoritative: it deletesnode_modulesfirst, installs the exact pinned versions, and errors ifpackage.jsonand the lockfile disagree. No range re-resolution, so the tree is byte-identical every run — ideal for CI/prod.So determinism comes from
npm cirefusing to re-resolve ranges and always starting from a clean slate.What a strong answer coversCommit the lockfile, not node_modules (bloat + platform-specific native builds).
npm installmay update the lockfile and reuse node_modules → can drift over time.npm ciwipes node_modules and installs the exact lockfile versions, erroring on mismatch.Determinism = no range re-resolution + clean-slate install.
Follow-ups they push on- Why might committing node_modules with native addons break on a teammate's machine?
- What happens with npm ci if you forgot to update the lockfile after editing package.json?
Red flag Using npm install in CI (non-deterministic, can silently bump versions) instead of npm ci.
source: npm docs — npm ci ↗