This is the group people skip, and then get caught by. Nobody fails an interview on map. Plenty of people stall on "what actually happens when you run npm install?" or "why does this package break when you import it?".
It is also the most practical group here. Everything below is something you will debug in a real job.
CommonJS and ES modules
In simple words: two different systems for splitting code into files. CommonJS came from Node and loads files while the program runs. ES modules are the standard and are analysed before anything runs.
That one difference explains everything else.
// CommonJS -- Node's original system
const express = require("express");
module.exports = { createServer };
// ES modules -- the standard
import express from "express";
export { createServer };| CommonJS | ES modules | |
|---|---|---|
| Resolved | while running | before running |
| Can be conditional | yes, require inside an if | no, import must be top level |
| What you get | a copy of the value at that moment | a live binding to the original |
this at top level | module.exports | undefined |
| Filename helpers | __dirname, __filename | import.meta.url |
| Top-level await | no | yes |
| Tree shakeable | no | yes |
Live bindings is the difference people cannot explain. With ESM, the importer sees updates to an exported variable. With CommonJS it got a copy and never sees the change.
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // 0
increment();
console.log(count); // 1 -- live binding, updated
// the same thing in CommonJS would still print 0Circular dependencies
Both handle cycles, badly, in different ways.
- CommonJS gives you a partially filled
module.exports— whatever had been assigned before the cycle closed. You getundefinedfor anything not yet set, with no error. - ESM hoists function declarations, so functions usually work, but a
constaccessed too early throws a ReferenceError from the temporal dead zone.
The ESM failure is louder, which is better. Either way the real fix is to break the cycle — usually by extracting the shared piece into a third module.
Dynamic import and top-level await
In simple words: import() as a function loads a module on demand and returns a promise. This is how code splitting works.
// only downloads the chart library when the user actually opens the chart
button.addEventListener("click", async () => {
const { renderChart } = await import("./heavy-chart.js");
renderChart(data);
});
// conditional loading, which a static import cannot do
const locale = navigator.language.startsWith("hi") ? "hi" : "en";
const messages = await import(`./locales/${locale}.js`);React.lazy is a thin wrapper over exactly this.
Top-level await lets a module await before it finishes loading. Anything importing it waits.
// config.mjs
const response = await fetch("/config.json");
export const config = await response.json();Useful, but it blocks every importer. Do not put a slow call at the top level of a widely imported module.
npm, lockfiles, and the ecosystem
In simple words: package.json says what you want. package-lock.json records exactly what you got. Without the lock, two machines can install different code from the same file.
| Field | Meaning |
|---|---|
dependencies | needed at runtime, shipped to production |
devDependencies | only needed to build and test |
peerDependencies | "you must provide this yourself" — how plugins avoid duplicate React |
optionalDependencies | install failure is not fatal — platform-specific binaries |
install versus ci
npm install # may update the lockfile, resolves ranges again
npm ci # installs exactly the lockfile, deletes node_modules first, fails if
# package.json and the lock disagreeUse npm ci in CI and Docker builds, always. It is faster and it guarantees the same tree every time. npm install in a pipeline can silently pull a new patch version and change your build.
The three package managers
- npm — bundled with Node, nested-then-flattened
node_modules - yarn — arrived first with lockfiles and workspaces; Yarn 2+ can skip
node_modulesentirely with Plug'n'Play - pnpm — one global content-addressed store, hard-linked into each project. Much less disk, much faster, and strict: a package you did not declare is not importable
That strictness is pnpm's real selling point. With npm's flat node_modules, you can accidentally import a transitive dependency and it works — until it is removed upstream and your build breaks for no visible reason.
Semver
In simple words: MAJOR.MINOR.PATCH. Major means breaking, minor means new features that do not break, patch means fixes.
| Range | Means | Example: from 1.2.3 |
|---|---|---|
^1.2.3 | patch and minor updates | up to but not including 2.0.0 |
~1.2.3 | patch updates only | up to but not including 1.3.0 |
1.2.3 | exactly this | only 1.2.3 |
>=1.2.3 <2 | explicit range | |
* | anything | never do this |
^ is npm's default and is the right choice most of the time. The catch: it trusts every maintainer to follow semver correctly, and plenty do not. That is exactly what the lockfile protects you from.
Bundlers
In simple words: a bundler follows your imports, produces a dependency graph, and outputs files a browser can load efficiently.
| What it is | Where it fits | |
|---|---|---|
| Webpack | the mature, configurable one | large legacy apps, unusual requirements |
| Vite | dev server on native ESM, Rollup for production | the default for new projects |
| Rollup | library-focused, cleanest output | publishing packages |
| esbuild | written in Go, extremely fast | powers other tools' transform step |
| Turbopack / Rspack | Rust rewrites of the Webpack idea | large apps wanting Webpack semantics, faster |
Why Vite feels instant in development: it does not bundle. Your browser requests modules over native ESM, and Vite transforms each file on demand. Adding files barely changes startup time. For production it does bundle with Rollup, because hundreds of separate requests would be slow over a network.
Tree shaking
In simple words: removing code you imported but never used. It only works because ESM imports are statically analysable.
import { formatDate } from "./utils.js"; // shakeable -- only this is kept
const { formatDate } = require("./utils"); // not shakeable -- dynamicThree things break it:
- CommonJS. The bundler cannot know what is used.
- Side effects. If importing a file does something — registers a polyfill, mutates a global — the bundler must keep it.
- Barrel files. A big
index.jsre-exporting everything can pull in far more than you asked for.
Declare that your package is side-effect free so bundlers can be aggressive:
{
"sideEffects": false,
"sideEffects": ["*.css", "./src/polyfills.js"]
}The exports field
Modern packages declare their entry points explicitly:
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}exports also seals the package: anything not listed cannot be imported. That is why deep imports like some-lib/dist/internal/thing suddenly stop working after an upgrade.
Babel, transpiling and polyfilling
In simple words: two different problems that people conflate.
- Transpiling rewrites new syntax into older syntax. Arrow functions become
function. Optional chaining becomes anif. - Polyfilling adds missing functions at runtime.
Array.prototype.flatorPromiseare added if absent.
Syntax cannot be polyfilled — old parsers throw on it before any code runs. Functions cannot be transpiled — there is nothing to rewrite, the method simply does not exist.
// .browserslistrc drives both. Tools read it and only include what is needed.
{
"browserslist": ["> 0.5%", "last 2 versions", "not dead"]
}With @babel/preset-env and useBuiltIns: "usage", Babel inspects your code and injects only the polyfills your targets actually lack. Widening your browser support list makes the bundle bigger — that is the trade.
Source maps
In simple words: a file mapping your minified output back to the original source, so a stack trace points at real line numbers.
//# sourceMappingURL=app.js.map| Setting | Trade-off |
|---|---|
eval-cheap-module-source-map | fast rebuilds, good enough for development |
source-map | full accuracy, slow — production |
hidden-source-map | generated, but not referenced in the bundle |
| none | fastest, unreadable stack traces |
For production, the usual approach is to generate full source maps and upload them to your error tracker (Sentry, Bugsnag) rather than serve them publicly — you get readable traces without publishing your source.
Monorepos
In simple words: several packages in one repository, sharing dependencies and tooling.
{ "workspaces": ["packages/*", "apps/*"] }Workspaces symlink local packages into each other's node_modules, so apps/web can import { Button } from "@acme/ui" and get your local source with no publishing step.
Turborepo and Nx add the part that matters at scale: a task graph with caching. Change one package and only its dependents rebuild; unchanged results come from cache, locally or shared with the team.
You do not need deep knowledge here. Know what a workspace is, why symlinking beats copying, and that the caching layer is what makes a large monorepo bearable.
Publishing a package
If asked how you would ship a library:
- Build both formats — ESM for bundlers, CommonJS for older Node
- Emit type declarations with
tsc --emitDeclarationOnly - Wire up
exports,main,moduleandtypes - Set
"sideEffects": falseif true - Restrict what ships with the
filesfield npm publish --access public
The single most common mistake is shipping only ESM. Any consumer still on CommonJS then cannot require your package at all.
Ten questions
- 01What is the fundamental difference between CommonJS and ES modules?
CommonJS resolves imports while the program runs, so a
requirecan be conditional or inside a function. ES modules are parsed and linked before any code executes.Everything else follows from that: ESM enables tree shaking and top-level await, gives live bindings instead of copies, and requires imports to be at the top level.
- 02What is a live binding, and how does it differ from what CommonJS gives you?
With ESM, an import is a live reference to the exported variable. If the exporting module reassigns it, the importer sees the new value.
CommonJS copies the value at the moment of require, so a later reassignment in the exporting module is invisible to the importer.
- 03What is the difference between
npm installandnpm ci, and which belongs in CI?npm installre-resolves version ranges and may update the lockfile.npm cideletesnode_modules, installs exactly what the lockfile says, and fails if the lockfile andpackage.jsondisagree.Always
npm ciin CI and Docker — it is faster and guarantees the same dependency tree every run. - 04What does
^1.2.3allow, and how does it change below version 1.0.0?It allows any patch and minor update below the next major, so anything from 1.2.3 up to but excluding 2.0.0.
Below 1.0.0 the rule tightens:
^0.2.3only allows patch updates, because in 0.x the minor number is treated as potentially breaking. - 05Why are peerDependencies used, and what problem do they solve?
They say "the host application must provide this" rather than installing a private copy. A React plugin declares React as a peer so that only one copy of React exists.
Without it you can end up with two React instances, which breaks hooks and context in ways that are very hard to diagnose.
- 06Why is Vite so much faster than Webpack in development?
It does not bundle for development. It serves your source over native ES modules and transforms each file on demand as the browser requests it, so startup barely grows with project size.
For production it does bundle, using Rollup, because hundreds of separate network requests would be slower than a few optimised files.
- 07What three things stop tree shaking from working?
Using CommonJS, because the bundler cannot statically determine what is used. Side effects at import time, which force the bundler to keep the module. And barrel files that re-export everything, pulling in far more than you imported.
The
sideEffectsfield inpackage.jsonis how you tell bundlers it is safe to be aggressive. - 08What is the difference between transpiling and polyfilling?
Transpiling rewrites new syntax into older syntax — optional chaining becomes a conditional. Polyfilling adds missing runtime functions, like
Array.prototype.flat.They are not interchangeable: syntax cannot be polyfilled because old parsers fail before execution, and a missing method cannot be transpiled because there is nothing to rewrite.
- 09Should production source maps be served publicly?
Usually not. Generate full source maps, upload them to your error tracker, and either do not deploy them or use hidden source maps so the bundle does not reference them.
You get readable stack traces in your monitoring without publishing your source code to anyone who opens devtools.
- 10You upgrade a package and a deep import like
lib/dist/internal/xbreaks. Why?The package added an
exportsfield. As well as declaring entry points,exportsseals the package — any path not listed becomes unimportable, even though the file is still physically there.The fix is to use a documented entry point, or ask the maintainer to export the path you need.
Six exercises
- 01
Prove live bindings exist
Write two ESM files where one exports a mutable counter and a function that increments it. Import both and show the value changes for the importer. Then write the CommonJS equivalent and show it does not.
- 02
Break a circular dependency
Create a genuine cycle in CommonJS and observe the partially filled exports. Recreate it in ESM and observe the temporal dead zone error instead. Then fix it by extracting the shared piece into a third module.
- 03
Read your own lockfile
Pick a dependency in a real project and trace it through
package-lock.json: which version resolved, why that one, and which packages depend on it. Then runnpm lsfor the same package and compare. - 04
Measure code splitting
Take any page that imports a heavy library. Build and note the bundle size. Convert the import to a dynamic
import()behind a user action, rebuild, and record the difference in the initial bundle. - 05
Publish a dual-format package
Build a tiny utility to both ESM and CommonJS with type declarations, wire up the
exportsfield, and verify withnpm packthat only the files you intended are included. Test bothimportandrequire. - 06
See browserslist change the output
In a project using preset-env, build once targeting modern browsers only, then again including an old target. Diff the output size and find one specific transform or polyfill that the wider target added.