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.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- 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.
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.
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 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.
npx live-server is your friend.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.
'use strict' before converting. Tools like ESLint's no-implicit-globals rule help automate this.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.
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.node --experimental-loader ./hooks.mjs --trace-warnings app.jscurl -I https://cdn.example.com/modules/analytics.js (check for 404 or CORS headers).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
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Advanced JS. Mark it forged?
5 min read · try the examples if you haven't