Intermediate0 questionsFull Guide

JavaScript ES Modules Interview Questions

ES Modules are the official JavaScript module system. Learn import/export, dynamic imports, and how they differ from CommonJS.

The Mental Model

Picture a city's electrical grid. Every building doesn't generate its own power from scratch. Buildings are connected to the grid, request exactly the power they need, and the grid ensures each building gets a stable, isolated supply. One building drawing extra power doesn't drain another. The grid manages distribution, prevents conflicts, and makes sure everything that needs power gets it without chaos. Modules are that grid for JavaScript. Without modules, every script shares one global scope — one giant power pool where any file can accidentally drain or corrupt another's variables. Modules give each file its own scope. Variables declared in one module don't leak into others. You explicitly export what you want to share and explicitly import what you need. The module system manages the connections. The key insight: modules are not just about code organisation — they're about creating explicit contracts between files. An import is a live binding to the exported value, not a copy. When the exported value changes, every importer sees the update. And modules are evaluated once and cached — no matter how many files import the same module, the module's code runs exactly once.

The Explanation

Named exports and imports

// math.js — named exports
export const PI = 3.14159

export function add(a, b) { return a + b }
export function multiply(a, b) { return a * b }

// Can also export declarations separately:
const subtract = (a, b) => a - b
const divide   = (a, b) => a / b
export { subtract, divide }

// Rename on export:
export { subtract as sub, divide as div }

// ─── Importing ───────────────────────────────────
// Named import — must match exported name
import { add, multiply } from './math.js'
add(2, 3)        // 5
multiply(2, 3)   // 6

// Rename on import:
import { add as sum } from './math.js'
sum(2, 3)  // 5

// Namespace import — everything under one object:
import * as math from './math.js'
math.add(2, 3)  // 5
math.PI         // 3.14159

Default exports

// user.js — one default export per module
export default class User {
  constructor(name) { this.name = name }
  greet() { return `Hi, I'm ${this.name}` }
}

// utils.js — function as default
export default function formatDate(date) {
  return date.toLocaleDateString('en-GB')
}

// ─── Importing default ────────────────────────────
// No curly braces — name it whatever you want
import User       from './user.js'
import formatDate from './utils.js'
import MyUser     from './user.js'  // different name — fine

// Default + named in one import:
// api.js
export default function fetchUser() { ... }
export const BASE_URL = 'https://api.example.com'

import fetchUser, { BASE_URL } from './api.js'

ESM is statically analysed — this changes everything

ES Module imports are not executable code — they're declarations. The module graph is resolved before any code runs. This is what enables tree-shaking, circular dependency detection, and type checking.

// ✓ Valid — import declarations are hoisted and resolved statically
import { add } from './math.js'

// ❌ Invalid — you can't conditionally import in ESM
if (condition) {
  import { add } from './math.js'  // SyntaxError
}

// ❌ Invalid — import path must be a string literal
const path = './math.js'
import { add } from path  // SyntaxError

// ✓ For dynamic imports, use import() — a function, not a declaration
if (condition) {
  const { add } = await import('./math.js')  // dynamic — returns a Promise
}

Live bindings — the critical ESM distinction

ESM exports are live bindings, not copies. If a module updates an exported variable, all importers see the new value. This is fundamentally different from CommonJS, which copies values.

// counter.js
export let count = 0
export function increment() { count++ }

// main.js
import { count, increment } from './counter.js'

console.log(count)  // 0
increment()
console.log(count)  // 1 — sees the update! Live binding.
count = 5           // TypeError: Assignment to constant variable
                    // Importers can READ live bindings but not REASSIGN them

Dynamic import() — code splitting and lazy loading

// import() returns a Promise for the module's namespace object
// Loads the module only when the code runs, not at parse time

// Lazy load a heavy component:
async function loadEditor() {
  const { Editor } = await import('./Editor.js')
  document.body.appendChild(new Editor().render())
}

// Conditional loading — load polyfill only if needed:
async function init() {
  if (!window.IntersectionObserver) {
    await import('intersection-observer')  // polyfill
  }
  startApp()
}

// Route-based code splitting (React):
const Dashboard = React.lazy(() => import('./Dashboard'))
const Settings  = React.lazy(() => import('./Settings'))
// Dashboard.js is not downloaded until Dashboard is rendered

// Dynamic path (with variable):
async function loadLocale(lang) {
  const { messages } = await import(`./locales/${lang}.js`)
  return messages
}
// Note: bundlers can statically analyse template literals with limited dynamic parts

CommonJS vs ESM — the differences that matter in practice

// ─── CommonJS (Node.js legacy) ───────────────────────
const path = require('path')              // synchronous, blocking
const { readFile } = require('fs')        // copies the value at require() time
module.exports = { myFunction }           // single exports object
module.exports = function() {}            // replace entire exports

// require() can appear anywhere, anytime — even inside functions or conditionals
function loadPlugin(name) {
  return require(`./plugins/${name}`)     // dynamic path — works in CJS
}

// ─── ES Modules ──────────────────────────────────────
import path from 'path'                  // asynchronous, static declaration
import { readFile } from 'fs/promises'   // live binding
export { myFunction }                    // named export

// In Node.js, to use ESM:
// Either use .mjs extension or set "type": "module" in package.json
// Cannot mix require() and import in the same file

Circular dependencies — what actually happens

// a.js
import { b } from './b.js'
export const a = 'A'
console.log('a sees b:', b)  // may be undefined — depends on evaluation order

// b.js
import { a } from './a.js'
export const b = 'B'
console.log('b sees a:', a)  // 'A' — if a.js was evaluated first

// ESM handles circular imports: the bindings exist but may be uninitialized
// at the time first code runs. They fill in as modules evaluate.
// The fix: move shared code to a third module that neither a.js nor b.js imports

Module patterns — before and after native modules

// IIFE module pattern (pre-ESM — 2010 era)
const Counter = (function() {
  let count = 0  // private — not accessible outside
  return {
    increment() { count++ },
    getCount()  { return count }
  }
})()

Counter.increment()
Counter.getCount()  // 1
Counter.count       // undefined — private

// AMD (RequireJS — browser async loading before ESM)
define(['jquery', 'underscore'], function($, _) {
  return { myFunction() { ... } }
})

// UMD — works as AMD, CommonJS, or global (legacy libraries)
;(function(root, factory) {
  if      (typeof define === 'function' && define.amd) define(factory)
  else if (typeof module !== 'undefined') module.exports = factory()
  else    root.myLibrary = factory()
})(this, function() { return { ... } })

// Modern ESM — all of the above replaced by:
// export / import — that's it

Tree shaking — why named exports matter

// utils.js — named exports
export function formatDate(d)  { ... }  // 2kb
export function formatCurrency(n) { ... }  // 3kb
export function parseCSV(str) { ... }   // 8kb

// main.js — only imports formatDate
import { formatDate } from './utils.js'

// Bundler (Webpack/Rollup/Vite) statically sees that only formatDate is used
// formatCurrency and parseCSV are excluded from the bundle — tree-shaken
// Final bundle: only 2kb for this module, not 13kb

// ❌ Anti-pattern — barrel re-exports with side effects prevent tree shaking
// index.js (barrel)
export * from './formatDate'
export * from './formatCurrency'
export * from './parseCSV'
import './setupPolyfills'  // side effect — bundler must include all exports

// ✓ Better: import directly from the specific file
import { formatDate } from './utils/formatDate'

Common Misconceptions

⚠️

Many devs think import and require() are interchangeable — but actually they work on fundamentally different systems. require() is synchronous, evaluated at runtime, and copies values. import is asynchronous (the module graph resolves before execution), statically analysed at parse time, and creates live bindings. You cannot use require() to load an ES module in Node.js without dynamic import(), and you cannot use import declarations inside conditionals or functions.

⚠️

Many devs think default exports are better than named exports because they're simpler — but actually named exports are almost always preferable for libraries and shared utilities. Named exports are statically analysable for tree shaking, provide better IDE autocomplete (the name is fixed), and make refactoring easier (rename in one place). Default exports can be imported with any name, which leads to inconsistency across a codebase and breaks automatic tooling that tracks symbol usage.

⚠️

Many devs think modules are re-evaluated every time they're imported — but actually modules are evaluated exactly once, the first time they're imported, and the result is cached. Every subsequent import gets the same cached module object. This is what makes module-level singletons (like a database connection or configuration object) work correctly — they're initialized once, shared everywhere.

⚠️

Many devs think ES module live bindings mean importers can reassign the exported variable — but actually importers can read live bindings but cannot reassign them. Only the module that owns the export can change its value. The binding is live in that readers see updates made by the exporter, but it's read-only from the importer's perspective. Attempting to assign to an imported binding throws a TypeError.

⚠️

Many devs think dynamic import() is just a workaround — but actually it's a fully specified part of the language with important use cases impossible with static imports: conditional loading, lazy loading for performance (code splitting), loading user-selected content (like locale files), and loading modules in environments where the path isn't known at build time. It's not a workaround — it's the intentional solution for runtime module loading.

⚠️

Many devs think circular imports are always a bug that causes errors — but actually ES Modules handle circular dependencies through live bindings. If module A imports from module B and B imports from A, the bindings exist from the start — they may just be uninitialized when first accessed. The key is evaluation order: the first module in the cycle to finish evaluating has its exports available. Circular imports are usually a design smell but they don't always fail.

Where You'll See This in Real Code

Vite's development speed comes directly from native ES Modules — instead of bundling everything on dev server start, Vite serves each file as an ES Module directly to the browser and lets the browser's native module loader handle imports. The browser only requests files it actually needs. When you edit a file, only that module is invalidated. This is only possible because ESM's static import graph allows per-file invalidation, which CJS's dynamic require() cannot support.

Next.js's code splitting is entirely dynamic import() under the hood — every page in a Next.js app is lazily loaded via import(). React.lazy() and Next.js's dynamic() both use import() to load components only when they're first rendered. The result is that a user visiting /home never downloads the JavaScript for /dashboard. This dramatically reduces initial bundle size for large applications.

Webpack's tree shaking was what drove the JavaScript ecosystem's migration from CJS to ESM. Rollup pioneered it, Webpack adopted it, and now every major bundler relies on ESM's static structure to eliminate dead code. The impact is real: importing lodash as import { debounce } from 'lodash-es' instead of require('lodash') reduces the included code from ~70kb to ~2kb — a 97% reduction for that dependency alone.

TypeScript's module resolution strategy directly mirrors ES Module semantics — when TypeScript sees import { User } from './user', it checks if User is exported from user.ts. If you remove the export, TypeScript gives a compile error in every file that imports it. This wouldn't be possible if imports were dynamic like require() — static imports are what give TypeScript the information to do cross-file type checking.

Node.js's dual package hazard is a real production problem — when a library ships both CJS and ESM versions, the CJS version and ESM version are treated as different modules by Node's module system. If a singleton (like a class or registry) is instantiated in the CJS version and then the ESM version imports it again, you get two separate instances. Libraries like React had to carefully handle this to avoid consumers getting two React instances when mixing CJS and ESM code.

Web workers use importScripts() (CJS-like) in classic mode, but support native ESM via new Worker('./worker.js', { type: 'module' }). Module workers get the full ESM import/export system, top-level await, and dynamic import() inside the worker context. As applications move computation to workers for performance, understanding module semantics in worker contexts becomes increasingly important.

Interview Cheat Sheet

  • ESM: static, live bindings, evaluated once, async resolution
  • CJS: dynamic, value copies, evaluated on each require(), synchronous
  • Named exports: multiple per module, statically analysable, tree-shakeable
  • Default export: one per module, importer chooses the name
  • Live binding: importers see updated values but cannot reassign
  • import() — dynamic, returns Promise, enables code splitting and lazy loading
  • Tree shaking: bundlers eliminate unused named exports — works only with ESM
  • "type": "module" in package.json — makes all .js files ESM in Node.js
  • Circular imports: bindings exist, may be uninitialized on first access
  • Modules cached after first evaluation — singletons work correctly
💡

How to Answer in an Interview

  • 1.ESM vs CJS — live bindings vs copies is the key technical distinction
  • 2.Tree shaking explanation with the lodash example shows real production impact
  • 3.Dynamic import() for code splitting is the answer to "how do you optimise bundle size"
  • 4.The module evaluation singleton behaviour explains how database connections and config singletons work — great for Node.js interviews
  • 5.Vite's development speed tied to native ESM shows you understand the ecosystem deeply
📖 Deep Dive Articles
Modern JavaScript: ES6+ Features Every Developer Must Know13 min read

Practice Questions

No questions tagged to this topic yet.

Tag questions in Admin → Questions by setting the "Topic Page" field to javascript-modules-interview-questions.

Related Topics

JavaScript Performance Interview Questions
Advanced·6–10 Qs
JavaScript async/await Interview Questions
Intermediate·5–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz