ES Modules — Barrel Files Kill Tree-Shaking
An update added 35KB to your bundle? A barrel file with a default export likely blocked tree-shaking.
- ESM uses
exportto expose andimportto 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 withoutas - Dynamic
import()returns a Promise — lazy load modules on user interaction - In Node.js, enable ESM with
"type": "module"in package.json; browsers use
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.
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.
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.
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.
- 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.
Logger, another calls it MyLogger.Dynamic import(): Optimizing Performance
Static imports are resolved at parse time, meaning the browser downloads them before executing a single line of code. Dynamic 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.import()
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).
.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.import() is the async lazy-loading function — returns a Promise.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 , while ESM uses require()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.
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.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.import() from CJS to ESM.require() an ESM module from CJS — use dynamic import().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 can be called conditionally and require()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.
"sideEffects": false, or importing CSS/fonts that bundle as side effects."sideEffects": false in package.json to unlock maximal dead code elimination.The Silent Bundle Bloat That Broke Our Lighthouse Score
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.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.- 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.
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")."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.import() returns a module with undefined exports at runtime__dirname. Use import.meta.url to build absolute paths../utils.js). In bundlers, configure resolve.extensions to include .js, .ts, .jsx. Ensure the module exists in node_modules or in the specified path..catch() to the dynamic import promise and log the error with full stack trace. Check the module URL for relative path resolution errors.Key takeaways
import() is a function that returns a Promise—ideal for code splitting and reducing initial bundle size.require() is dynamic and can be called inside loops.Common mistakes to avoid
4 patternsUsing `import` without adding `type="module"` to the script tag
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.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
resolve.extensions doesn't include .js.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
Trying to use `require()` inside an ES Module file
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.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 Questions on This Topic
Explain 'Tree Shaking' and why ES Modules make it possible while CommonJS makes it difficult.
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.Frequently Asked Questions
That's Advanced JS. Mark it forged?
3 min read · try the examples if you haven't