JavaScript & the runtime

Modules and bundling

ESM semantics — static structure, live bindings, singleton modules, cycles and top-level await — versus CommonJS, Node interop and package exports, tree shaking and code splitting, and what Vite and Rolldown do with all of it.

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.

Know

  • ESM is static. 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.
  • Live bindings, not copies. An import is a read-only view of the exporting module's binding; if the exporter reassigns export let count, importers see the new value. CommonJS require copies the value of module.exports at call time.
  • Modules are singletons per URL. The first import evaluates the module and caches the instance; every later import gets the same one (which is why module-level state is shared, and why it leaks across requests on a server). A different query string is a different module.
  • Evaluation order is depth-first, dependencies before dependents. Cycles are allowed: the module that is still evaluating exposes bindings in their TDZ, so reading one throws or yields undefined for var/functions hoisted; fix cycles by extracting the shared piece or importing lazily inside a function.
  • Top-level await makes the module async and delays every dependent; use it for genuinely one-time setup, not for fetching data on import.
  • Dynamic 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 attributes (ES2025): import data from './x.json' with { type: 'json' } for JSON modules; import defer (deferred evaluation) and import source are stage 3 in 2026.
  • CommonJS interop in Node: "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).
  • Package entry points: the 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).
  • Bundlers: in development Vite serves native ESM per file and pre-bundles dependencies (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.
  • Import maps (<script type="importmap">) resolve bare specifiers in the browser without a bundler; Nuxt exposes an experimental entryImportMap.
  • In Nuxt: a dependency shipping CJS with ESM syntax or a wrong 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.

How it works

Live bindings and the cycle trap:

counter.ts
export let count = 0
export function increment() { count++ }
main.ts
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
a.ts
import { b } from './b'
export const a = 'a'
console.log(b) // fine: b.ts finished evaluating before a.ts resumes
b.ts
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:

package.json
{
  "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:

app/components/Heavy/Editor.client.vue (script)
// 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] })
})
nuxt.config.ts
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
})
Gotcha· Module-level state is process-level state on the server

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.

Gotcha· `export default` from a CJS package

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Reproduce the cycle above in Vite and read the error; break the cycle by moving the shared value to a third module.
  • Publish @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.
  • Run nuxt analyze on this app, find the CodeMirror chunk, then change the dynamic import to a static one and compare the entry chunk size.

Be able to say

Be able to say· What are the differences between ES modules and CommonJS that still matter?

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

Be able to say· What does a bundler actually do with modules, and how does Vite differ between dev and build?

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

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.