Answer:
CommonJS (CJS) — Node's original module system:
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const { add } = require('./math');
- Synchronous:
requireloads and executes the module immediately. - Dynamic: you can
requireconditionally, inside functions, with computed paths. - Exports are a copy of the reference at export time.
ES Modules (ESM) — the JavaScript standard:
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from './math.mjs';
- Static: imports/exports are resolved before execution, enabling tree-shaking and better tooling.
- Asynchronous loading; supports top-level
await. - Exports are live bindings — importers see updated values if the exporting module changes them.
- Import paths generally need the file extension.
How Node picks the system:
.cjs→ always CommonJS;.mjs→ always ESM..js→ depends on the nearestpackage.json:"type": "module"means ESM,"type": "commonjs"(or absent) means CJS.
Interop:
- ESM can
importCommonJS modules (themodule.exportsbecomes the default export). - CommonJS cannot
requirean ESM module synchronously; use dynamicawait import(). __dirname/__filenamedon't exist in ESM — useimport.meta.url.
Interview tip: The core distinction is static + async (ESM) vs dynamic + synchronous (CJS). Static structure is what enables tree-shaking.
Answer:
Resolution algorithm (roughly):
- Core module? e.g.
require('fs')→ return the built-in. - Relative/absolute path?
./,../,/→ resolve the file, trying extensions.js,.json,.node, then a directory'sindex.jsor itspackage.jsonmain. - Bare specifier? e.g.
require('express')→ look in./node_modules, then the parent directory'snode_modules, walking up to the filesystem root.
require('fs'); // core
require('./utils'); // ./utils.js or ./utils/index.js
require('lodash'); // node_modules/lodash
Caching:
- After a module runs, its
module.exportsis stored inrequire.cache, keyed by the resolved absolute path. - Every later
requireof that path returns the same object — the module body runs only once.
// counter.js
let count = 0;
module.exports = { inc: () => ++count, get: () => count };
// a.js and b.js both require('./counter') share the SAME instance
This is the basis of the singleton pattern in Node and why module top-level code (like a DB connection) runs a single time.
Cache gotchas:
- Two different resolved paths (e.g., two copies of a package in different
node_modules) are separate cached instances — a common source of "instanceof fails" or "duplicate singleton" bugs. - You can delete
require.cache[resolvedPath]to force a reload (used by some hot-reload tools), but it's rarely a good idea in production.
Answer:
package.json is the manifest at the root of a Node project.
Common fields:
{
"name": "my-api",
"version": "1.4.2",
"type": "module",
"main": "dist/index.js",
"exports": { ".": "./dist/index.js" },
"scripts": {
"start": "node dist/index.js",
"dev": "node --watch src/index.js",
"test": "jest"
},
"dependencies": { "express": "^4.19.2" },
"devDependencies": { "jest": "^29.7.0" },
"engines": { "node": ">=18" }
}
main/exports— entry points (exportsis the modern, stricter form controlling what's importable).scripts— commands runnable vianpm run <name>(startandtestalso work withoutrun).engines— declares the supported Node version.
Semantic Versioning (semver): MAJOR.MINOR.PATCH
- MAJOR — breaking changes.
- MINOR — new, backward-compatible features.
- PATCH — backward-compatible bug fixes.
Range specifiers:
| Range | Allows | Example matches for 1.4.2 |
|---|---|---|
^1.4.2 | minor + patch (no major) | 1.4.2 → <2.0.0 |
~1.4.2 | patch only | 1.4.2 → <1.5.0 |
1.4.2 | exact | only 1.4.2 |
* / latest | anything | any version |
^ is the npm default because it gets bug fixes and features without (in theory) breaking changes. The exact installed versions are pinned in the lockfile.
Answer:
dependencies — packages your code needs to run in production (e.g., express, pg). Installed by default for anyone using your package.
devDependencies — packages needed only during development/build/test (e.g., jest, eslint, typescript, bundlers). Skipped when installing with npm install --omit=dev (formerly --production), which keeps production images lean.
peerDependencies — a compatible version your package expects the host to provide, rather than bundling its own copy. Typical for plugins/libraries that must share a single instance with the host:
// a React component library
"peerDependencies": { "react": ">=17" }
This avoids two copies of React and the "invalid hook call" class of bugs.
optionalDependencies — installs are attempted but failures don't abort (e.g., platform-specific binaries).
Lockfile (package-lock.json, or yarn.lock/pnpm-lock.yaml):
- Records the exact version and integrity hash of every package in the entire tree (including transitive deps).
- Guarantees reproducible installs — everyone and every CI machine gets the identical tree, regardless of when they install.
- Commit it. Use
npm ciin CI, which installs strictly from the lockfile and fails ifpackage.jsonand the lock disagree — faster and deterministic.
Interview tip: package.json says what ranges are acceptable; the lockfile says what was actually installed.
Answer:
npm scripts:
Defined under scripts in package.json and run via npm run <name>:
"scripts": {
"start": "node index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"lint": "eslint src",
"build": "tsc"
}
- Run with
npm run dev.startandtestare special and also work asnpm start/npm test. - During a script,
node_modules/.binis added toPATH, so you can call locally-installed tools (jest,eslint,tsc) by name without a global install. - Lifecycle hooks: a script named
pre<name>runs before, andpost<name>runs after (e.g.,pretestruns beforetest).
npx:
npx executes a package binary without a permanent global install:
npx create-react-app my-app # download + run a scaffolder once
npx eslint . # run the locally installed eslint
npx cowsay hello # try a tool without installing it
Resolution order: a matching binary in the local node_modules/.bin first, otherwise npx downloads the package to a temporary cache and runs it.
Why it matters:
- Avoids polluting the global namespace and version drift from globally installed CLIs.
- Ensures you run the project's pinned version of a tool rather than whatever is installed globally.