Modules look solved until a dependency ships broken ESM, a circular import gives you undefined, or a bundle is twice the size it should be. Senior interviews ask about ESM versus CommonJS because the difference still explains real production incidents, and about bundling because the Vite dev server and the production build behave differently on purpose.
import/export are declarations, hoisted and resolved before any code runs, so tooling can analyse the dependency graph without executing it. That is what makes tree shaking, import.meta.glob, typed routes and auto-imports possible.export let count, importers see the new value. CommonJS require copies the value of module.exports at call time.undefined for var/functions hoisted; fix cycles by extracting the shared piece or importing lazily inside a function.await makes the module async and delays every dependent; use it for genuinely one-time setup, not for fetching data on import.import() returns a promise and is the code-splitting boundary for bundlers (each dynamic import becomes a chunk). import.meta.url gives the module's URL (path resolution in layers), Vite adds import.meta.env and import.meta.glob.import data from './x.json' with { type: 'json' } for JSON modules; import defer (deferred evaluation) and import source are stage 3 in 2026."type": "module" or .mjs selects ESM, .cjs forces CommonJS; ESM can import CJS (default export is module.exports; named exports only if statically detectable); require(esm) works unflagged in Node 20.19+/22.12+ for modules without top-level await; __dirname/require do not exist in ESM (import.meta.dirname, createRequire).exports field with conditions (import, require, types, default, browser, node, development, custom), subpath exports (./utils), and "sideEffects": false (or a list) so bundlers may drop unused modules. Dual packages risk the dual package hazard (two copies of the same module, one ESM, one CJS, with separate state).optimizeDeps) so a CJS library or a 500-file package becomes one module; in production it bundles with Rolldown (Vite 8, replacing Rollup) into hashed chunks with modulepreload hints. Chunks split on dynamic imports and on manualChunks/advancedChunks; hashing enables immutable caching (/_nuxt/* in this app). Tree shaking needs ESM, no side effects on import, and /*#__PURE__*/ annotations for calls whose results are unused.<script type="importmap">) resolve bare specifiers in the browser without a bundler; Nuxt exposes an experimental entryImportMap.exports map shows up as "Cannot use import statement outside a module" or "does not provide an export named"; fixes are build.transpile: ['pkg'], an alias to a working entry, or importing the default and destructuring, as the Nuxt ESM concept page describes.Live bindings and the cycle trap:
export let count = 0
export function increment() { count++ }
import { count, increment } from './counter'
increment()
console.log(count) // 1: a live view of the binding, not a copied 0
// count = 5 -> SyntaxError/TypeError: imports are read-only
import { b } from './b'
export const a = 'a'
console.log(b) // fine: b.ts finished evaluating before a.ts resumes
import { a } from './a'
export const b = 'b'
console.log(a) // ReferenceError: `a` is in its temporal dead zone while a.ts is still evaluating
A package that ships both formats correctly:
{
"name": "@team/utils",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./format": {
"types": "./dist/format.d.ts",
"import": "./dist/format.mjs",
"require": "./dist/format.cjs"
}
},
"files": ["dist"]
}
Code splitting on purpose, and the Nuxt-side fixes for a broken dependency:
// The dynamic import is the chunk boundary: CodeMirror lands in its own hashed chunk, loaded on mount.
onMounted(async () => {
const [{ EditorView }, { basicSetup }] = await Promise.all([import('@codemirror/view'), import('codemirror')])
view = new EditorView({ parent: host.value!, extensions: [basicSetup] })
})
export default defineNuxtConfig({
build: { transpile: ['legacy-esm-package'] }, // let Vite/Rolldown transform a package that ships ESM syntax as CJS
alias: { 'broken-pkg': 'broken-pkg/dist/index.mjs' }, // point at a working entry
vite: { optimizeDeps: { include: ['cjs-only-dependency'] } }, // pre-bundle a CJS dep the crawler finds late
})
export const cache = new Map() is one Map for every request Node serves, and one per browser tab on the client. For a cache that is fine with bounds; for anything per user it is a data leak. This is the module-system root of the "cross-request state pollution" warning in Vue's SSR guide and the memory-leaks section here.
import { thing } from 'cjs-pkg' may fail while import pkg from 'cjs-pkg'; const { thing } = pkg works, because Node can only expose CJS named exports it detects statically. Bundlers paper over this in dev and then production differs; test the built output.
The exact Node versions where require(esm) is unflagged (20.19 and 22.12 onward) and its remaining limitation with top-level await; also whether import defer has reached stage 4.
@team/utils to a local Verdaccio with the exports map, consume it from an ESM and a CJS project, and inspect which file each resolved.nuxt analyze on this app, find the CodeMirror chunk, then change the dynamic import to a static one and compare the entry chunk size."ESM is static and hoisted, so the dependency graph is known before any code runs, which enables tree shaking and tooling that reads imports without executing them; CommonJS require is a runtime call that copies module.exports. ESM imports are live bindings, so reassigning an exported let is visible to importers; CJS gives you a snapshot. ESM is strict, async-capable with top-level await, and its cycles surface as temporal-dead-zone errors rather than silent undefineds. Interop still bites: named exports from CJS depend on static detection, require(esm) only became unflagged in Node 20.19 and 22.12, and dual packages need a correct exports map or you get two copies with separate state."
"It resolves the import graph from the entry points, transforms each module, and emits chunks: everything reachable from the entry in one file plus a chunk per dynamic import boundary, with hashed names so they cache forever. Tree shaking removes exports nothing reaches, which requires ESM and side-effect-free modules. Vite skips bundling in development: it serves each module as native ESM and only pre-bundles dependencies with esbuild or Rolldown so a CommonJS or 500-file package becomes one request; in production it bundles everything with Rolldown, which is why 'works in dev, breaks in build' usually means a dependency interop or side-effect problem."
Nothing yet. Notes filed here show up on this page and the section overview.
Closures, scope, this and prototypes
Lexical scope and the temporal dead zone, what a closure captures and what it keeps alive, the four rules for this, arrow functions and class fields, prototypes underneath classes, private fields, descriptors, Proxy and Reflect, and equality.
Collections, iterators and generators
Map, Set and their 2025 methods, WeakMap and WeakRef, the iteration protocol, generators and async iteration, iterator helpers, grouping and upsert, change-by-copy arrays, typed arrays and base64, and web streams.