ESM uses export to expose and import to consume — no more global pollution
Named exports (export const) force explicit naming at import site, improving refactoring
Default exports (export default) are for the file's primary responsibility, rename without as
Dynamic import() returns a Promise — lazy load modules on user interaction
In Node.js, enable ESM with "type": "module" in package.json; browsers use
✦ Definition~90s read
What is Modules in JavaScript?
ES Modules (ESM) are the official JavaScript module system standardized in ECMAScript 2015, designed to solve the fundamental problems of dependency management, scope isolation, and static analysis that plagued earlier approaches like script tags and CommonJS. Unlike CommonJS's runtime require() which loads modules synchronously and executes them in order, ESM uses import and export declarations that are parsed statically at load time — meaning the JavaScript engine knows exactly which bindings a module imports and exports before executing a single line of code.
★
Think of JavaScript modules like filing cabinets.
This static structure is what enables tree shaking: bundlers like Webpack, Rollup, and esbuild can analyze the import graph at build time and safely eliminate any exported code that isn't actually imported anywhere, dramatically reducing bundle sizes in production. ESM also supports top-level await, cyclic dependencies (with caveats), and strict mode by default, making it the foundation for modern JavaScript development in both browsers and Node.js (since v12.17+).
Where ESM fits in the ecosystem is as the successor to CommonJS (Node.js's require/module.exports) and AMD (RequireJS). CommonJS remains dominant in legacy Node.js packages and server-side code that hasn't migrated, but its dynamic nature — you can require() inside conditionals, loops, or functions — makes static analysis impossible, so bundlers must include entire modules even if only one function is used.
ESM's static imports are the opposite: they must be at the top level, and the module graph is fully known at parse time. This is why you should use ESM for any new library or application where tree shaking matters, and why you should avoid CommonJS for browser-targeted code.
However, ESM is not always the right choice: if you need dynamic loading based on runtime conditions (e.g., loading different polyfills per browser), you should use import() (dynamic import) which returns a promise and is still statically analyzable for the imported module's shape, but the decision to load is deferred to runtime.
The critical nuance that trips up even experienced developers is that ESM's static analysis is only as good as the module graph you give it. Barrel files — index.js files that re-export multiple modules with export * from './foo' or export { bar } from './bar' — seem like a clean API surface but actually destroy tree shaking.
When a bundler sees import { baz } from './barrel', it must include every module that the barrel re-exports, because it cannot statically determine which re-exports are actually used without fully resolving the entire transitive dependency graph. This is not a bug in bundlers; it's a consequence of ESM's semantics where export * creates live bindings that could be mutated.
The result: your 10KB utility library becomes 200KB in production because a single import pulls in everything. The fix is to import directly from the source module (import { baz } from './utils/baz') or use tools like modularize-imports to rewrite barrel imports at build time.
Plain-English First
Think of JavaScript modules like filing cabinets. Each file is a drawer with labeled folders (named exports) and one main folder on top (default export). The import command is like reaching into the drawer to pull out what you need. Before modules, developers had to tape folders together (IIFEs) or use messenger services (CommonJS). Now, the browser or Node.js handles the filing neatly and only opens drawers when you actually need them — that's the 'tree shaking' superpower.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Before ES Modules (ESM) became the official standard, the JavaScript ecosystem was a fragmented landscape of workarounds. Developers relied on IIFE patterns to prevent global scope pollution, eventually giving way to CommonJS (require) for Node.js and AMD for browser-side loading. With the arrival of ES2015, JavaScript finally gained a native, static module system.
Modern modules are more than just a way to split files; they enable critical optimizations like Tree Shaking (removing unused code) and Code Splitting (loading only what the user needs). This guide moves beyond basic syntax to explore how to architect clean, scalable module structures in production-grade applications.
How ES Modules Actually Work — And Why Barrel Files Break Them
ES Modules (ESM) are JavaScript's official module system, defined by the ECMAScript spec. Unlike CommonJS's dynamic require(), ESM uses static import/export statements that are parsed before execution. This static structure enables tree-shaking — bundlers like Webpack, Rollup, and esbuild can analyze which exports are actually used and eliminate dead code at build time.
Each module file is a separate scope. Named exports are bound to their original module, not copied. When you write import { foo } from './bar', the bundler knows exactly which symbol you need. If bar.js exports 20 functions but you only import one, the other 19 can be removed — provided the bundler can statically trace every import. This is O(n) per module graph, not O(n²), because the graph is acyclic and deterministic.
Use ES Modules for any new JavaScript project — browser, Node.js (v12+), or bundler-based. The static analysis unlocks smaller bundles, faster load times, and better caching. The catch: any pattern that obscures the import graph, like barrel files (re-exporting everything from an index.js), forces bundlers to assume every export is used, killing tree-shaking and bloating your bundle.
Barrel Files Are Tree-Shaking Killers
Re-exporting everything from an index.js makes the bundler treat all exports as potentially used — dead code elimination stops at the barrel.
Production Insight
A team migrated a 200-component UI library to barrel exports for cleaner imports. Bundle size jumped 40% because every unused icon, helper, and utility was included in every consumer build.
Symptom: Lighthouse performance score drops 15 points, Time to Interactive increases by 2 seconds on a mid-tier mobile device.
Rule: Never re-export more than 5–10 items from a single barrel file. Prefer direct imports from the source module.
Key Takeaway
ES Modules are statically analyzable — bundlers can only tree-shake what they can see at parse time.
Barrel files (index.js re-exports) create a false dependency graph that prevents dead code elimination.
Always import directly from the leaf module, not from a barrel, to preserve tree-shaking in production builds.
thecodeforge.io
Modules Javascript
Named Exports: The Foundation of Clean APIs
Named exports are the preferred way to export multiple values, utilities, or constants from a single file. They force explicit naming at the call site, which improves code discoverability and makes 'Find All References' much more reliable in modern IDEs.
When you export a named binding, you must import it with the exact same name (or alias with as). This strictness means renaming an export automatically updates all imports in your editor — if your tooling supports it. In practice, named exports also enable better tree shaking because bundlers can statically analyse which names are used.
utility/math.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io/thecodeforge/utils/math.js// Exporting individual components as they are definedexportconstPI = 3.14159265359;
exportconst multiply = (a, b) => a * b;
// Functional logic with explicit namingexportfunctioncalculateForgeLoad(cpu, ram) {
return (cpu * 0.7) + (ram * 0.3);
}
// main.js - Consuming named exportsimport { calculateForgeLoad, PI as MathPI } from'./io/thecodeforge/utils/math.js';
console.log(`ForgeThreshold: ${calculateForgeLoad(80, 16)}`);
console.log(`Constant: ${MathPI}`);
// Namespace Import: Useful for large utility librariesimport * as ForgeMathfrom'./io/thecodeforge/utils/math.js';
console.log(ForgeMath.multiply(10, 5));
Use named exports by default. They make refactoring safer, bundle size smaller, and code reviews easier. Reserve default exports for one-per-file components or classes that are the clear 'main' export.
Production Insight
Named exports in a barrel file (re-exporting from an index.js) prevent tree-shaking because bundlers treat the barrel as a single entry point.
Always import directly from the module file, not the barrel, to guarantee dead code elimination.
Rule: if your bundle has unused code, the first suspect is a barrel file with named re-exports.
Key Takeaway
Named exports force explicit import names and enable tree-shaking.
Barrel files with named re-exports are the #1 cause of bundle bloat.
Prefer direct imports from module files, not barrel indices.
Named Export vs Default Export Decision
IfUtility function or constant that might be used by multiple modules
→
UseUse named export — keeps imports explicit and enables tree-shaking
IfSingle class or component that is the main purpose of the file
UseUse only named exports — mixing default and named adds cognitive load and reduces refactorability
Default Exports: Primary Module Identity
A module can have exactly one default export. This is typically reserved for the 'main' thing a file represents—such as a Class or a React Component. Unlike named exports, you can rename a default import to anything you like without using the as keyword.
The trade-off: default exports lose the benefit of consistent naming. When you import a default, you can call it anything, which makes it harder to find all usages. Some style guides (including AirBnb's) discourage default exports for this reason. At TheCodeForge, we allow default exports only when the module exports a single 'primary' entity with a clear identity.
services/Logger.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io/thecodeforge/services/Logger.jsexportdefaultclassLogger {
constructor(prefix = 'FORGE') {
this.prefix = prefix;
}
log(message) {
console.log(`[${this.prefix}] ${newDate().toISOString()}: ${message}`);
}
}
// You can still export secondary constants alongside a defaultexportconst DEFAULT_LOG_LEVEL = 'INFO';
// app.jsimportForgeLogger, { DEFAULT_LOG_LEVEL } from'./io/thecodeforge/services/Logger.js';
const logger = newForgeLogger();
logger.log(`System initialized at level: ${DEFAULT_LOG_LEVEL}`);
Output
[FORGE] 2026-03-18T12:00:00.000Z: System initialized at level: INFO
One default export per file — like a single CEO per company.
Importers can rename it arbitrarily, making global renaming tools less effective.
Mixing default and named exports in the same file is allowed but ugly — avoid it.
Tree shaking is less aggressive on default exports because the bundler cannot guarantee unused status.
Production Insight
Default exports often lead to inconsistent naming across a codebase — one import calls it Logger, another calls it MyLogger.
When you need to find all usages of that class, the IDE will show two different names, making refactoring impossible.
Rule: If you use default exports, enforce a naming convention (e.g., the default export name must match the filename) via lint rule.
Key Takeaway
Default exports are convenient but harm refactorability.
Use them only when the module has one clear 'primary' responsibility.
Never mix default and named exports in a production codebase — pick one style.
thecodeforge.io
Modules Javascript
Dynamic import(): Optimizing Performance
Static imports are resolved at parse time, meaning the browser downloads them before executing a single line of code. Dynamic import() returns a Promise, allowing you to 'lazy load' modules only when they are needed—for example, when a user clicks a button or navigates to a specific route.
This is the core mechanism for code splitting. Modern bundlers (Webpack 5, Rollup, Vite) automatically treat dynamic imports as split points, creating separate chunks. Dynamic imports also work in Node.js 14+ and are essential for server-side lazy loading (e.g., loading a heavy analytics module only when a specific API is hit).
dashboard.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// heavy-analytics.jsexportfunctionrunHeavyAudit() {
console.log("Running intensive data audit...");
}
// dashboard.jsconst auditBtn = document.querySelector('#audit-trigger');
auditBtn.addEventListener('click', async () => {
try {
// The module is fetched over the network only on clickconst { runHeavyAudit } = awaitimport('./heavy-analytics.js');
runHeavyAudit();
} catch (err) {
console.error("Failed to load the audit module:", err);
}
});
Output
// Network request triggered only on user interaction.
Always attach a .catch() to dynamic imports. If the module fails to load (network error, missing file), the rejection is silent unless handled. Common failure modes: wrong path (relative to calling module, not base URL), CORS errors in browser, or missing package in Node.js.
Production Insight
Dynamic imports are the gateway to performance, but they introduce a new class of errors.
In production, a network failure for a large module can leave the UI in a broken state if not handled.
Rule: always show a fallback UI or retry mechanism when using dynamic import in user-facing code.
Key Takeaway
Dynamic import() is the async lazy-loading function — returns a Promise.
Use it for code splitting: routes, heavy libraries, infrequent actions.
Never use dynamic import without error handling — it's silent in production.
The Great Divide: CommonJS vs. ES Modules
Node.js was built on CommonJS (require), which is synchronous. ES Modules are asynchronous and static. Understanding how they interact is vital for modern full-stack development. CommonJS uses module.exports and require(), while ESM uses export and import.
The key differences: CommonJS exports a copy (a shallow clone) at the time of require, whereas ESM provides 'live bindings' — if the exporting module changes a variable, the importing module sees the change. Also, this in CommonJS refers to the module itself, but in ESM, this at the top level is undefined.
example.cjs vs example.mjsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// LEGACY: CommonJS (example.cjs)const path = require('path');
module.exports = { name: 'ForgeLegacy' };
// MODERN: ES Modules (example.mjs or .js with type: module)import { fileURLToPath } from'url';
import { dirname } from'path';
// Note: __dirname and __filename do not exist in ESM!const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
exportconst metaData = { directory: __dirname };
// Top-level await is ONLY available in ESM
const response = await fetch('https://api.thecodeforge.io/status');const status = await response.json();
export { status };
Output
// ESM enables powerful features like top-level await and tree-shaking.
You cannot require() an ES Module — you must use import() (dynamic import) or write the consuming file as ESM. Conversely, you can import a CJS module from ESM, and Node.js will wrap module.exports as the default export.
Production Insight
Mixing CommonJS and ESM in the same Node.js project is a common source of bugs.
A CJS module that uses module.exports = { ... } appears as a default export to ESM consumers — named imports won't work without a named export in the CJS module or a bundler interop.
Rule: avoid mixing both systems in the same project. Either commit to ESM or stay CJS. If you must share, use dynamic import() from CJS to ESM.
Key Takeaway
ESM is static and live; CJS is dynamic and copies.
You cannot require() an ESM module from CJS — use dynamic import().
Top-level await is exclusive to ESM — use it to simplify initialization.
Tree Shaking and Dead Code Elimination with ESM
Tree shaking is the bundler's ability to eliminate unused exports from the final bundle. It relies on the static structure of ESM: because import and export declarations are top-level and immutable, the bundler can safely determine which exports are actually used. CommonJS cannot be tree-shaken because require() can be called conditionally and module.exports is a mutable object.
For tree shaking to be effective, your code must be side-effect-free. A side effect is any code that performs an action when imported, e.g., modifying the global scope, writing to a file, or interacting with the DOM. If a module has side effects, the bundler must include it even if none of its exports are used.
Marking your package as side-effect-free in package.json with "sideEffects": false tells bundlers it's safe to shake unused exports.
Side effects are like glue: a module that sets window.myGlobal is 'sticky' and cannot be shaken, even if no import uses it.
Every named export is a potential leaf that can be pruned if unused.
Default exports are like a single heavy branch — harder to shake off entirely.
Barrel files create many small branches that look connected — bundlers keep them all to be safe.
Production Insight
Tree shaking fails silently — you won't get an error, just a larger bundle.
Common causes: importing from a barrel, using default exports, forgetting to add "sideEffects": false, or importing CSS/fonts that bundle as side effects.
Rule: run a bundle analyzer regularly and enforce bundle size budgets in CI.
Key Takeaway
ESM static imports enable tree shaking; CJS cannot be shaken.
Set "sideEffects": false in package.json to unlock maximal dead code elimination.
Avoid barrel files and default exports to maximize shaking efficiency.
The File:// Trap: Why Your Modules Won't Load Locally
You wrote perfect import statements. Code runs on your coworker's machine. Yours? Dead silent. The error message is cryptic. The cause is embarrassingly simple: you opened the file with file:// instead of http://. Browsers enforce strict CORS policies on ES modules. They refuse to load module scripts from local files. This isn't a bug. It's a security feature. Without it, any local HTML file could import scripts from your file system. Every senior dev has wasted an afternoon on this. The fix is trivial: serve your files. Use npx serve . or python -m http.server. Or better yet, integrate a dev server into your workflow from day one. Learning this early saves hours of debugging. Do not assume modules work like regular scripts. They don't.
index.htmlHTML
1
2
3
4
5
6
7
8
9
10
11
<!-- ❌ This crashes with file:// -->
<script type="module">
import { add } from './math.js';
console.log(add(2, 3));
</script>
<!-- ✅ This works with http:// -->
<script type="module">
import { add } from './math.js';
console.log(add(2, 3));
</script>
Output
Uncaught DOMException: Failed to resolve module specifier './math.js'.
Relative references must start with '/', './', or '../'.
Even in development, never open module files directly. Use a local server. Many bundlers (Vite, Webpack Dev Server) handle this automatically. If you're writing vanilla JS modules, npx live-server is your friend.
Key Takeaway
Modules require HTTP(S). File:// is not optional — it's a hard block.
Strict Mode: The Silent Enforcer You Can't Turn Off
Every module runs in strict mode. There is no opt-out. This isn't a suggestion — it's baked into the spec. Semantics shift. Undeclared variables throw ReferenceErrors instead of creating globals. Assignments to non-writable properties fail silently no more. The this keyword inside a module's top-level scope is undefined, not window. Functions duplicate parameter names? SyntaxError. If you've written pre-ES5 JavaScript, these feel like restrictions. But they're design improvements. They catch bugs at parse time instead of runtime. They prevent accidental global leaks that plagued traditional scripts. If you're migrating legacy code to modules, run it through a linter first. Expect breakage. Code that relied on implicit globals or this === window will fail immediately. Good. That means it was already broken — you just hadn't noticed.
module.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
// This fails silently in non-module scripts// But throws in a module:
'use strict'; // implicit in modules
total = 42; // ReferenceError: total is not definedfunctionoldAPI() {
return this === window; // true in script, false in module
}
When converting a legacy script to a module, wrap all code in an IIFE or explicit function scope. Check for implicit globals using 'use strict' before converting. Tools like ESLint's no-implicit-globals rule help automate this.
Key Takeaway
If your code assumes sloppy mode, it will break in modules. That's a feature, not a flaw.
Export Anything: Why Modules Are Not Just For Functions
Modules export more than functions. You can export constants, configuration objects, class definitions, even other modules after re-exporting. This flexibility is powerful but often underused. Consider a constants file. Instead of inlining magic numbers across modules, export them. Need a default configuration for a library? Export an object. Building a plugin system? Export classes for consumers to extend. The pattern is consistent: declare, then export. But watch out — exported values are live bindings for named exports. If you export a primitive, changing it in the source module doesn't update the import. For objects and arrays, mutations propagate. That's by design. Default exports, however, are not live bindings. They copy the value at import time. This subtle difference causes bugs when developers mix the two without understanding the semantics.
Named exports are live bindings. If you export a mutable object and mutate it in the source module, all importers see the change. Default exports are not. Use this intentionally for shared state, but avoid it for primitives — reassigning a named export won't update imports.
Key Takeaway
Named exports are live; default exports are snapshots. Pick the right tool for the job.
● Production incidentPOST-MORTEMseverity: high
The Silent Bundle Bloat That Broke Our Lighthouse Score
Symptom
Lighthouse performance score dropped from 92 to 64 after a routine dependency update. Main bundle grew by 35KB with no new features added. The app loaded fine locally but felt sluggish on 3G networks.
Assumption
The team assumed that any unused named export would be automatically removed by the bundler (Webpack/Rollup). They believed that side-effect-free ESM imports guarantee dead code elimination.
Root cause
A barrel file (index.js) re-exported several modules including one that imported a heavy third-party library. Because one of the re-exports was a default export, the bundler could not safely tree-shake the other named exports in the same barrel — it treated the whole barrel as potentially having side effects.
Fix
Replaced the barrel pattern with direct imports at each consumer site. Used sideEffects: false in package.json and configured Webpack's module.rules to mark known side-effect-free directories. Added a CI check that fails if the main bundle grows beyond a threshold in bytes.
Key lesson
Barrel files are tree-shaking killers — import directly from module files instead.
Default exports in a barrel can block dead code elimination for the entire barrel.
Always track bundle size in CI to catch silent bloat early.
Production debug guideQuick-reference for common import errors in browsers and Node.js4 entries
Symptom · 01
Browser console: "Uncaught SyntaxError: Cannot use import statement outside a module"
→
Fix
Add type="module" to <script> tag. Verify the script tag has the attribute. If using a bundler, ensure it outputs ESM-compatible code (e.g., Webpack output.library.type: "module").
Symptom · 02
Node.js: "SyntaxError: Cannot use import statement outside a module"
→
Fix
Add "type": "module" to package.json or rename file to .mjs. Check that all parent folders are also in an ESM context. If mixing CJS/ESM, use .cjs for CJS files.
Symptom · 03
Dynamic import() returns a module with undefined exports at runtime
→
Fix
Verify the module path is correct relative to the calling file (not relative to the base URL). In Node.js, dynamic imports resolve via the current module's URL, not __dirname. Use import.meta.url to build absolute paths.
Symptom · 04
Bundler warning: "Module not found: Error: Can't resolve '...'"
→
Fix
Check file extension. ESM requires the full path including extension in browsers (e.g., ./utils.js). In bundlers, configure resolve.extensions to include .js, .ts, .jsx. Ensure the module exists in node_modules or in the specified path.
★ Quick Debug Cheat Sheet for Module Import IssuesTop 3 production import failures and the commands to diagnose them
Dynamic import fails silently — module not loaded, no error in console−
Immediate action
Open browser DevTools Network tab or Node.js `--trace-warnings` flag.
curl -I https://cdn.example.com/modules/analytics.js (check for 404 or CORS headers)
Fix now
Add a .catch() to the dynamic import promise and log the error with full stack trace. Check the module URL for relative path resolution errors.
Node.js: ERR_MODULE_NOT_FOUND for a file that exists+
Immediate action
Verify file extension and case sensitivity (Linux/macOS are case-sensitive).
Commands
node -e "console.log(require.resolve('./module'))" (if using CJS) or check with `fs.existsSync`
node --experimental-specifier-resolution=node app.js (optional, but can mask issues)
Fix now
Ensure both import path and file system path match exactly. ESM requires extension in browsers; for Node.js, set "type": "module" and include .js extension (unless using bundler that resolves).
Import works in development but fails in production build+
Immediate action
Check the bundler output — inspect the built JS file for missing modules.
Named exports (export const X) require curly braces and support Tree Shaking better than default exports.
2
Default exports (export default X) are best for primary classes or components; they don't require braces.
3
Dynamic import() is a function that returns a Promise—ideal for code splitting and reducing initial bundle size.
4
ES Modules are static
imports must be at the top level and are hoisted. CommonJS require() is dynamic and can be called inside loops.
5
In ESM, global variables like __dirname are replaced by import.meta.url patterns.
6
Set `"sideEffects"
false` in package.json to enable tree shaking for your library.
7
Avoid barrel files
they block tree shaking and introduce fragile re-export chains.
Common mistakes to avoid
4 patterns
×
Using `import` without adding `type="module"` to the script tag
Symptom
Browser throws SyntaxError: Cannot use import statement outside a module. App fails to load with no clear stack trace in production because minifiers may swallow the error.
Fix
Add type="module" to the <script> tag. If using a bundler, ensure it outputs in a format that doesn't require the attribute (bundled output usually runs as normal scripts).
×
Forgetting the file extension in imports when targeting browsers
Symptom
Browser returns a 404 for the import path because the server can't resolve extensionless modules. Node.js may also fail if resolve.extensions doesn't include .js.
Fix
Always include the full file extension in browser imports: import { helper } from './helpers.js'. In bundlers, configure resolve.extensions appropriately. For Node.js ESM, extensions are required unless using experimental-specifier-resolution=node.
×
Mixing default and named exports in a barrel file
Symptom
Tree-shaking fails, bundle grows. Developers are confused about which syntax to use when importing. Some tools (like ESLint import/export rules) may report false positives.
Fix
Avoid barrel files entirely. If you must use them, export only named exports and avoid default exports in the barrel. Alternatively, export a single default object containing all named exports.
×
Trying to use `require()` inside an ES Module file
Symptom
Node.js throws ReferenceError: require is not defined because ESM does not have require in the global scope. Some older patterns like const fs = require('fs') stop working.
Fix
Replace require() with import. If you need dynamic behaviour, use await import() instead. For interop, consider using the createRequire function from module module to create a local require function.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain 'Tree Shaking' and why ES Modules make it possible while CommonJ...
Q02SENIOR
What is the 'Static Analysis' benefit of ESM over CJS?
Q03SENIOR
How do you simulate `__dirname` in a Node.js ES Module environment?
Q04SENIOR
What happens if two modules have a circular dependency in ESM vs. Common...
Q05SENIOR
Implement a dynamic import loader that retries the network request 3 tim...
Q06SENIOR
Compare 'Default' and 'Named' exports in terms of refactorability in a l...
Q01 of 06SENIOR
Explain 'Tree Shaking' and why ES Modules make it possible while CommonJS makes it difficult.
ANSWER
Tree shaking is dead code elimination that removes unused exports from the final bundle. ES Modules enable it because their import/export statements are static — they appear at the top level and are never conditional. This allows bundlers to statically analyse which exports are actually imported. CommonJS requires() can be dynamic (called inside if-blocks, loops, or functions), so the bundler cannot safely determine which exports are used without executing the code. Therefore, CommonJS modules are included in their entirety.
Q02 of 06SENIOR
What is the 'Static Analysis' benefit of ESM over CJS?
ANSWER
Static analysis means the bundler (or analyser) can parse the import/export statements without executing the code. This enables: (1) tree shaking — removing unused exports; (2) deterministic module graph — no race conditions; (3) faster bundling — because the dependency graph is known upfront; (4) dead code detection in linting — tools like ESLint can report unused imports. CommonJS requires code execution to resolve the module graph, which is why tools like Webpack had to use static analysis heuristics that often fail.
Q03 of 06SENIOR
How do you simulate `__dirname` in a Node.js ES Module environment?
ANSWER
In ESM, __dirname and __filename are not available because the module system doesn't use those globals. Use import.meta.url to get the file URL of the current module, then convert it to a path using the fileURLToPath function from the url module: import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);. Alternatively, Node.js 22+ includes import.meta.dirname and import.meta.filename as experimental features.
Q04 of 06SENIOR
What happens if two modules have a circular dependency in ESM vs. CommonJS?
ANSWER
In CommonJS, circular dependencies are handled by returning a partial copy of module.exports that may be incomplete if the dependency is not yet fully exported. This can lead to undefined values for exports that are defined later. In ESM, because exports are live bindings, circular dependencies work more gracefully: importing modules see the current state of the exported variable, which starts as undefined and later gets the actual value. However, you must still avoid relying on the value of a binding before it is initialized — any export that uses let or var can be accessed before its initializer runs (hoisted), but const exports have a temporal dead zone. Best practice: avoid circular dependencies entirely by refactoring shared logic into a third module.
Q05 of 06SENIOR
Implement a dynamic import loader that retries the network request 3 times if it fails.
ANSWER
``javascript
export async function importWithRetry(modulePath, retries = 3, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await import(modulePath);
} catch (error) {
if (attempt === retries) {
throw new Error(Failed to load ${modulePath} after ${retries} attempts: ${error.message});
}
console.warn(Attempt ${attempt} failed for ${modulePath}, retrying in ${delay}ms...);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // exponential backoff
}
}
}
// Usage: const { runHeavyAudit } = await importWithRetry('./heavy-analytics.js');
``
In production, also consider a timeout for each attempt to avoid hanging indefinitely on a slow network.
Q06 of 06SENIOR
Compare 'Default' and 'Named' exports in terms of refactorability in a large-scale codebase.
ANSWER
Named exports are far more refactorable because each import uses the exact exported name. When you rename a named export (e.g., export const fetchUser => export const loadUser), your IDE can automatically update all imports that use that name. Default exports can be imported under any name (e.g., import MyComponent from './button' or import Button from './button'), so renaming the default export doesn't change import names. This makes it nearly impossible to track all usages of a default export without manual searching. In a large codebase with hundreds of modules, default exports lead to naming inconsistencies and increased cognitive load during refactoring. Recommendation: use named exports for all utilities, hooks, helpers, and multiple items; use default exports only for single-export components where the filename is the authoritative name (and enforce a lint rule that the default import name must equal the filename).
01
Explain 'Tree Shaking' and why ES Modules make it possible while CommonJS makes it difficult.
SENIOR
02
What is the 'Static Analysis' benefit of ESM over CJS?
SENIOR
03
How do you simulate `__dirname` in a Node.js ES Module environment?
SENIOR
04
What happens if two modules have a circular dependency in ESM vs. CommonJS?
SENIOR
05
Implement a dynamic import loader that retries the network request 3 times if it fails.
SENIOR
06
Compare 'Default' and 'Named' exports in terms of refactorability in a large-scale codebase.
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
When should I use a default export vs a named export?
Industry standard at TheCodeForge suggests using named exports for utilities and constants because they force consistent naming across the codebase. Use default exports only for the 'Single Responsibility' of a file, like a specific React component. Avoid 'mixing' both in one file as it makes the import syntax cumbersome for other developers.
Was this helpful?
02
Can I import a CommonJS module in an ES Module file?
Yes, in Node.js, you can use import defaultExport from './file.cjs'. Node will wrap the module.exports of the CJS file as the default export of the ESM import. However, you cannot use require() inside an ES Module file; you must stick to import.
Was this helpful?
03
Why do I get 'Uncaught SyntaxError: Cannot use import statement outside a module'?
This happens in the browser when you forget to add type='module' to your <script> tag. Without this attribute, the browser treats the file as a legacy script where 'import' is a reserved word but not a functional command.
Was this helpful?
04
Does 'import' copy the value or create a reference?
ES Modules provide 'live bindings.' If a module exports a variable and then changes its value, the importing module sees that change. This is a major difference from CommonJS, which exports a snapshot/copy of the value at the time of the require call.
Was this helpful?
05
How do I use dynamic import in Node.js without bundlers?
Dynamic import() is supported natively in Node.js 14+. Just call await import('./module.js') inside an async function or at the top level if your module is ESM (since ESM supports top-level await). Ensure the file extension is included and the path is relative to the current file (using import.meta.url for absolute paths if needed).
Was this helpful?
06
What is a barrel file and why is it problematic?
A barrel file is an index.js (or similar) that re-exports everything from multiple modules. Example: export { Button } from './Button'; export { Card } from './Card';. Problem: bundlers treat barrel as a single entry point and cannot tree-shake individual exports — they include everything. Also, circular dependencies are more likely. Better to import directly from the module file instead of the barrel.