> cs·fundamentals
interview 0% 20m read
4.4 ★ core [J] 14 interview Q's

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

CommonJSES Modules
Importconst x = require('x')import x from 'x'
Exportmodule.exports = …export / export default
Loadingsynchronous, runs on first requireasync, statically resolved before execution
Timingdynamic — require anywhere, even conditionallyimport 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 inimport.meta.dirname / import.meta.url
A .js file's meaning depends on the nearest package.json 'type' field.

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.

bare: require(‘lodash’)relative: require(’./util’)/app/src/api/ node_modules?miss ↑/app/src/ node_modules?miss ↑/app/ node_modules/lodashfound ✓importer: /app/src/api/x.js→ /app/src/api/util.jsone hop, no walkingMODULE CACHE (keyed by resolved absolute path)second require/import of the same path → same instance, code does NOT re-run
FIG 1 · resolution Two specifier kinds, two algorithms. A bare name like 'lodash' walks UP the directory tree checking each node_modules folder until it hits the filesystem root. A relative name like './util' resolves once, against the importing file's own directory. Either way, the loaded module object is cached — a second require/import of the same resolved path returns the very same instance, not a fresh copy.

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.

Predict the output: shared module state
// 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
2

require("./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 (or NODE_ENV=production) skips these — keep your runtime image lean.

Version ranges use semver prefixes, and the prefix controls how far an install may drift:

RangeAllows updates toMeans
^1.2.31.x.x (<2.0.0)caret — minor + patch; the npm default
~1.2.31.2.x (<1.3.0)tilde — patch only
1.2.3nothingexact pin
* / latestanythingavoid — non-reproducible
Caret tracks the leftmost non-zero segment; for 0.x, ^0.2.3 only allows 0.2.x.

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 done

02 Curated reading

03 Knowledge check

knowledge check3 questions · pass ≥ 70%
  1. 01easy

    In semver, `^1.2.3` allows updates up to (but not including):

  2. 02easy

    package-lock.json exists to:

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

  • Commonly asked mid concept very common CommonJS vs ES Modules: name the real differences (syntax, loading, this, __dirname, top-level await).

    - Syntax: CJS uses require() / module.exports; ESM uses import / export.
    - Loading: CJS is synchronous and loads at runtime, so require() can be conditional/dynamic. ESM is asynchronous with a static parse phase — imports are hoisted and resolved before the body runs (use dynamic import() for conditional loading).
    - Bindings: CJS exports a *copied value*; ESM exports *live bindings* (re-exported values stay in sync).
    - this: top-level this is module.exports in CJS, but undefined in ESM.
    - __dirname/__filename: available in CJS; in ESM you derive them from import.meta.url.
    - Top-level await: allowed in ESM, not in CJS.

    Node picks the mode from "type" in package.json (or .cjs/.mjs extension).

    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 ↗
  • Commonly asked mid trick common What's the difference between `exports = foo` and `module.exports = foo` in CommonJS? Which one actually works, and why?

    Only module.exports = foo works to replace the whole export.

    At module start, Node does roughly exports = module.exports = {}exports is just a *local variable pointing at the same object* as module.exports. What gets returned to the requirer is module.exports.

    - exports.foo = ... works because you are mutating the shared object.
    - exports = foo only reassigns the local variable exports; module.exports still points at the original {}, so the requirer gets an empty object.
    - module.exports = foo correctly replaces what is returned.

    Rule: use exports.x = ... to add properties, but module.exports = ... to export a single thing.

    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 ↗
  • Commonly asked junior concept common 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 with npm 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 lists react as 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.

    Red flag Putting runtime libs in devDependencies — works locally, then crashes in a --omit=dev production install.

    source: npm docs — package.json dependencies ↗
  • Commonly asked mid concept common 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. So 1.9.0 is fine; 2.0.0 is not. (Special case: for 0.x, ^0.2.3 is treated as >=0.2.3 <0.3.0 — a 0.x minor 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.json pins exact resolved versions for reproducible installs.

    Red flag Thinking ^ and ~ are the same, or trusting that minor bumps are always non-breaking.

    source: npm docs — About semantic versioning ↗
  • Commonly asked mid concept common What does package-lock.json do, and why should you commit it? What's the difference between `npm install` and `npm ci`?

    package-lock.json records the *exact* version, resolved URL, and integrity hash of every package in the tree (including transitive deps). Because package.json only specifies ranges, the lockfile is what makes installs reproducible — everyone and CI get byte-identical trees. Commit it.

    - npm install reads package.json, may update the lockfile to satisfy ranges, and adds/removes packages. Good for development.
    - npm ci installs strictly from the lockfile, errors if package.json and the lock disagree, and wipes node_modules first. Deterministic and faster — the right choice for CI and production builds.

    Red flag Gitignoring the lockfile (irreproducible builds) or using `npm install` in CI instead of `npm ci`.

    source: npm docs — npm ci ↗
  • Commonly asked mid debug common 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 its module.exports; the second require returns the same cached object — no re-execution. So a and b are the *same* object sharing the *same* c. a.inc() makes c 1, and b.get() reads that same c: 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.

    Red flag Expecting each require to give a fresh module — it returns the cached singleton.

    source: Node.js docs — Modules caching ↗
  • Commonly asked senior concept occasional 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, then foo.js/foo.json/foo.node, then foo/ as a directory (its package.json main/exports, else index.js).

    Bare specifier (foo): Node walks node_modules outward — ./node_modules/foo, then the parent's node_modules, up to the filesystem root — and uses the first match. Core modules (fs, path, or node: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_modules can nest.

    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 ↗
  • Commonly asked senior trick occasional What is a circular dependency between two CommonJS modules, and what does the importer actually receive?

    A circular dependency is a.js requiring b.js while b.js requires a.js. CommonJS doesn't deadlock — it returns a partially-completed module.exports.

    When a starts loading and requires b, b begins executing; if b then requires a, Node sees a is already in progress and hands b the **partial exports of a as they exist *right now* (whatever a had assigned before the require(b) line). If a hadn't exported the thing b needs yet, b sees undefined.

    So behavior depends on statement order** and is fragile. Symptom: a value is mysteriously undefined only 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 covers
    • CJS doesn't deadlock; it returns the partial exports of the in-progress module.

    • What b sees of a depends on what a had exported before its require(b) line.

    • Symptom: a dependency value is undefined depending on load order.

    • Fix: break the cycle, extract a shared module, or require lazily inside a function.

    Red flag Assuming a circular require throws or deadlocks — it silently returns half-initialized exports.

    source: Node.js docs — Modules: Cycles ↗
  • Commonly asked senior concept common 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's module.exports as 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) and require is synchronous, so historically require("esm-only-pkg") threw ERR_REQUIRE_ESM. The portable workaround is dynamic import(), 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 dynamic import() 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 covers
    • CJS from ESM: import x from 'cjs' — module.exports becomes the default export.

    • ESM from CJS: require() is restricted (ESM is async), classically ERR_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.

    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 ↗
  • Commonly asked senior concept occasional What does the "exports" field in package.json do, and how do conditional exports (import/require/default) work?

    The exports field defines a package's official entry points and, crucially, encapsulates it: once you declare exports, consumers can import only the paths you list — deep imports into internal files (pkg/lib/secret.js) are blocked. It supersedes main.

    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 via import/import(), require when loaded via require(), and default as 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 covers
    • exports declares entry points and encapsulates internals (blocks deep imports).

    • Conditional exports map a specifier to different files by condition.

    • import vs require lets one package ship both ESM and CJS builds (dual package).

    • Conditions match in order, most-specific first; default is the last-resort fallback.

    Red flag Adding an exports field and accidentally breaking consumers who deep-imported internal paths.

    source: Node.js docs — Packages: conditional exports ↗
  • Commonly asked mid concept occasional 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 audit scans 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 fix bumps within allowed ranges; a transitive fix may require the direct dependency to update, or an overrides entry 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 covers
    • Transitive = 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 audit scans the whole tree, so most findings are in indirect packages.

    • Fix via npm audit fix, upgrading the direct dep, or overrides to pin a patched version.

    Red flag Treating every npm audit finding as a critical blocker, or assuming you can only fix direct dependencies.

    source: npm docs — npm audit ↗
  • Commonly asked senior debug occasional 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 count is a read-only *view* of the exporter's count variable — not a snapshot taken at import time. When inc() mutates count inside a.mjs, the importer's view reflects the new value, so console.log(count) reads 1.

    Contrast with CommonJS: const { count } = require("./a") copies the value at require time, so calling inc() would not change your local count (it'd still be 0). Note you can *read* the live binding but not reassign it from the importer (count = 5 throws — imports are read-only).

    What a strong answer covers
    • ESM 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-check

    What does main.mjs print?

    Red flag Assuming ESM imports are value snapshots like CJS — they're live bindings, so mutations show through.

    source: MDN — export (live bindings) ↗
  • Commonly asked mid concept common How do you get __dirname and __filename in an ES module, and why aren't they available like in CommonJS?

    In CommonJS, __dirname and __filename are 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 you import.meta.url, the file's URL (a file:// 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 because import.meta.url is a URL, not a filesystem path (and on Windows or with spaces/special chars, naive string slicing breaks). Recent Node also exposes import.meta.dirname / import.meta.filename` as conveniences.

    What a strong answer covers
    • CJS injects __dirname/__filename via the module wrapper; ESM has no wrapper.

    • ESM exposes import.meta.url (a file:// URL) instead.

    • Convert with fileURLToPath(import.meta.url) then path.dirname(...).

    • Don't string-slice the URL — fileURLToPath handles Windows/encoding correctly.

    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 ↗
  • Commonly asked mid concept occasional 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 ci is what gives reproducibility without the bloat.

    What makes them differ:

    - npm install treats package.json as the source of truth: it resolves ranges, may update the lockfile, and reuses/patches an existing node_modules. Two installs at different times can yield different trees if a new in-range version was published.
    - npm ci treats the lockfile as authoritative: it deletes node_modules first, installs the exact pinned versions, and errors if package.json and the lockfile disagree. No range re-resolution, so the tree is byte-identical every run — ideal for CI/prod.

    So determinism comes from npm ci refusing to re-resolve ranges and always starting from a clean slate.

    What a strong answer covers
    • Commit the lockfile, not node_modules (bloat + platform-specific native builds).

    • npm install may update the lockfile and reuse node_modules → can drift over time.

    • npm ci wipes node_modules and installs the exact lockfile versions, erroring on mismatch.

    • Determinism = no range re-resolution + clean-slate install.

    Red flag Using npm install in CI (non-deterministic, can silently bump versions) instead of npm ci.

    source: npm docs — npm ci ↗