An app imports a single function from a barrel `index.js` whose body is nothing but `export * from './...'` lines covering forty modules, yet most of those modules still end up in the production bundle. What about that barrel undermines tree shaking, and how would you change it?
answer
- one import, forty modules resolved
- the parse happens regardless
- each module judged separately
- the worst member sets the floor
- deep imports shrink the graph
basics
~20 sA barrel makes every re-exported module part of the graph, so each must be parsed and, unless its top level is provably inert, evaluated. One impure module among forty is enough to pull itself in, and re-export chains amplify the cost.
solid answer
~50 s`export *` does not itself defeat elimination — a barrel over forty genuinely pure modules shakes fine. What it does is drag all forty into the module graph. Each is resolved and parsed, and each is then judged separately: any module whose top level is impure, or merely not provably pure, is retained even though the app touched none of its exports. Because the barrel also re-exports whatever those modules import, a single impure transitive dependency propagates in. Two costs follow: bundle bloat, and build time, since the parse happens whether or not the module survives. The fixes are to import deep paths directly (`import { formatDate } from 'lib/format/date.js'`), to prefer explicit `export { x } from './x.js'` over `export *` so names resolve without scanning, and to publish the package with an honest `sideEffects` declaration so pure files can be skipped.
code
javascript · 17 lines// components/index.js — one import here resolves and parses every listed module
export * from './button.js';
export * from './modal.js';
export * from './date-picker.js';
// components/date-picker.js — this top-level call keeps the file in every bundle
import { registerLocale } from '../i18n/locales.js';
registerLocale('en-GB');
export function DatePicker() {
/* ... */
}
// app.js
import { Button } from './components/index.js'; // date-picker.js still ships
// app.js, rewritten — only button.js enters the graph
import { Button } from './components/button.js';go deeper
Know that importing one name from an index file makes the build read every module that index re-exports, and that importing the specific file directly avoids that.
Explain the two distinct costs — unconditional parse of the whole re-exported set, then a per-module keep-or-drop decision based on that module's own top-level effects — and why one impure file among many is what actually bloats the bundle.
Show how you would confirm it: inspect the emitted bundle for modules you never imported, trace the import edge that pulled each one in, then fix the specific leaf doing import-time work rather than banning barrels by decree.
Own the package-boundary decision: whether shared libraries publish one index or subpath entry points, what that costs consumers in ergonomics, and how you keep the rule enforced as the package grows past the point where anyone reviews its index file.
## What a barrel is and why teams write them A barrel is an index module that re-exports a directory's public surface so consumers write one import instead of many: ```js // components/index.js export * from './button.js'; export * from './modal.js'; export * from './date-picker.js'; // ... forty more ``` Consumers then write `import { Button } from './components/index.js'`. The ergonomics are real; the build-time consequences are what interviewers probe. ## Step one: the whole directory joins the graph When the bundler resolves the barrel, it must resolve every specifier the barrel names. It cannot skip `./date-picker.js` on the grounds that nobody wants a date picker, because with `export *` the *set of names* the barrel exports is exactly the union of what each of those modules exports — so it has to read them all to know where `Button` comes from and to detect ambiguities. Forty modules are parsed, plus everything they import. This parse cost is unconditional. Even in the happy case where thirty-nine modules are dropped, you paid to read them, which is why large barrels are a common cause of slow dev-server startup and slow rebuilds. ## Step two: each module is judged on its own side effects Once in the graph, each module gets the ordinary treatment: unused bindings are dropped, and the module's evaluation is skipped only if its top level is provably free of observable behaviour. So the outcome is not "barrels break tree shaking" but something sharper: - Forty modules of pure declarations: all but one are dropped. The barrel cost you build time and nothing else. - One of the forty registers itself, patches a prototype, or calls an opaque function at the top level: that one is retained, along with everything it needs, though your app referenced none of it. - One of the forty imports a dependency with import-time effects: the dependency comes along too. With forty modules the odds of hitting the second or third case approach certainty, which is why the barrel gets blamed. It is not the mechanism; it is the blast radius. ## Step three: re-export chains amplify Barrels compose. A package barrel re-exports feature barrels which re-export component barrels. One import from the top pulls the transitive closure of every barrel below into the graph. It is also how circular structure sneaks in: module A imports the barrel, the barrel re-exports module B, and B imports the barrel back, creating a cycle whose evaluation order is easy to get wrong. ## What actually fixes it **Import deep, not through the barrel.** `import { formatDate } from 'lib/format/date.js'` puts exactly one module in the graph. For internal code this is the single most effective change; the cost is longer import paths. **Prefer explicit re-exports over `export *`.** ```js // clearer, and the name-to-module mapping is stated rather than derived export { Button } from './button.js'; export { Modal } from './modal.js'; ``` This does not by itself let the bundler skip parsing (it still resolves each specifier), but it removes the ambiguity-resolution work, keeps the barrel's surface intentional, and makes an accidental new export visible in review rather than automatic. **Declare side effects honestly.** A package whose files really are pure can say so in `package.json`, which lets the bundler skip evaluation of unused modules without proving purity itself. That is what makes a well-published library's barrel tolerable while an unmarked application barrel is not. **Keep effects out of leaf modules.** The barrel is only as shakeable as its worst member. One `registerComponent(...)` at the top of one file is enough to keep that file permanently in every consumer's bundle. **Consider not having a barrel at the package boundary.** Many libraries now publish subpath entry points instead, so consumers reach individual modules by path rather than through one giant index. The ergonomic loss is small; the graph shrinks dramatically. ## How to confirm the diagnosis Do not reason about it from source — inspect the emitted bundle and look for module identifiers you never imported, then trace which import edge pulled each one in. The chain almost always ends at a barrel plus one impure leaf. Reasoning alone tends to blame the barrel wholesale, when the actionable finding is usually a specific module doing work at import time.
- So is `export *` inherently unshakeable?No. A barrel over modules that are all pure declarations shakes down to just the one you used. What `export *` guarantees is that every re-exported module enters the graph and is parsed, because the barrel's export names are the union of theirs. The elimination then succeeds or fails per module, on that module's own side effects.
- Does switching from `export * from './x.js'` to `export { X } from './x.js'` make the barrel shakeable?It helps clarity and removes name-resolution work, but it does not stop the bundler from resolving and parsing `./x.js`. The build-time cost stays; what changes is that the barrel's surface is explicit, accidental exports do not leak in, and ambiguity between two modules exporting the same name cannot arise silently.
- Why do large barrels slow down a dev server even when the final bundle is small?Because the parse and graph-construction cost is paid before any elimination decision. Resolving one barrel import means resolving and reading every module it re-exports, transitively through nested barrels. Elimination happens afterwards and only shrinks the output, not the work already done — so startup and rebuild times track graph size, not bundle size.
saying these in an interview costs you the question
- Says barrels always break tree shaking, with no mechanism
- Thinks the bundler only parses the module the used export lives in
- Believes switching to explicit re-exports eliminates the parse cost
- Assumes a small final bundle means the barrel was free
- Blames `export *` rather than the impure leaf module