skip to content

In a bundled ES-module app, an unused `import { formatDate } from './utils.js'` disappears from the production bundle, but `import './analytics.js'` — an import statement with no bindings at all — is kept. Why does the bundler treat these two import forms differently?

level: juniorimportance: should knowfreq 50%

answer

  1. two import forms, two different requests
  2. one asks for a name, one for evaluation
  3. nothing to prove unused
  4. the effect is the point
  5. binding removal ≠ module removal

basics

~20 s

An unused named binding can be deleted because static analysis proves no code reads it. A bindings-free import asks for the module's evaluation rather than for a name, so its top-level code is the requested effect and must still run.

solid answer

~40 s

ES modules have a fixed import/export structure that a tool can read before any code runs, so a bundler can build a binding graph. `formatDate` is a named binding; if nothing in the graph reads it, the binding is provably dead and can go. `import './analytics.js'` declares no binding at all — its whole purpose is *evaluate this module*, which is how polyfills, CSS imports, custom-element registration and global setup are wired up. There is nothing to prove unused, and deleting it would change observable behaviour, so the bundler keeps it by default. Note these are two separate decisions: dropping an unused binding does not by itself drop the module, because the module may still have top-level side effects that have to run.

code

javascript · 19 lines
javascript
// utils.js — pure top level, nothing observable happens on evaluation
export function formatDate(d) {
  return d.toISOString().slice(0, 10);
}
export function slugify(s) {
  return s.toLowerCase().replace(/\s+/g, '-');
}

// analytics.js — the top level IS the point
window.__analyticsQueue = [];
window.addEventListener('error', (e) => {
  window.__analyticsQueue.push({ kind: 'error', message: e.message });
});

// app.js
import { slugify } from './utils.js'; // formatDate is never referenced -> droppable
import './analytics.js';              // no binding to drop; evaluation is requested

console.log(slugify('Hello World'));

go deeper

for a junior

Know that import './x.js' means "run this module" and creates no name, while a named import creates a binding that can be deleted if unused. Say plainly that polyfills and style imports are written the first way on purpose.

for a middle

Explain the two-step decision: an unused binding is provably dead and goes, but the module's evaluation is only skipped when its top level is provably harmless. Mention that static specifiers and top-level-only import declarations are what make the analysis possible at all.

for a senior

Show you can diagnose the production-only failure this causes — polyfills or registrations vanishing from a build while dev works fine — and describe how you would confirm it by inspecting the emitted bundle rather than guessing at configuration.

for a principal

Own the policy angle: whether your org's shared packages are allowed to rely on import-for-effect at all, since every such module is a permanent floor on bundle size and a hazard once someone declares the package side-effect-free.

## Tree shaking is a build-time optimisation, not a language feature Nothing in ECMAScript says unused exports may be deleted. What the language provides is a module format whose structure is *statically analysable*: `import` and `export` declarations may appear only at the top level of a module, their specifiers must be string literals, and the set of names a module imports and exports is therefore known after parsing, before a single line executes. Bundlers exploit that guarantee to compute which bindings are reachable from the entry point and drop the rest. That elimination pass is what people call tree shaking. ## The two import forms request different things ```js import { formatDate } from './utils.js'; // asks for a binding named formatDate import defaultThing from './utils.js'; // asks for the default binding import * as utils from './utils.js'; // asks for the namespace object import './analytics.js'; // asks for nothing but evaluation ``` At runtime, with no bundler involved, all four behave the same in one respect: the module is fetched, linked and evaluated exactly once, and every later import of the same specifier reuses the cached module record. The difference is only in what names the importing module gets. ## Why the unused named import is removable Building the binding graph, the analyser asks: does any reachable code read `formatDate`? If no, the local binding is dead. Removing the binding is always safe — a binding is just a name. Then comes a *second, independent* question: now that no binding from `./utils.js` is used, may the import of `./utils.js` itself be removed, skipping the module's evaluation? That is only safe if the module's top level does nothing observable. If `utils.js` is nothing but function and `const` declarations, the answer is yes and the module vanishes from the bundle. If it also calls `registerHelpers()` at the top level, the analyser cannot prove that call is harmless, so the module stays and only the unused binding goes. Conflating those two steps is the usual source of confusion when someone reports that "tree shaking doesn't work": the binding was removed, the module was not. ## Why the bindings-free import cannot be removed `import './analytics.js'` exists precisely to trigger evaluation. There is no binding whose disuse could license removal, and the code it runs is the point of writing it. Common real uses: ```js import 'core-js/stable'; // install polyfills onto built-ins import './register-elements.js';// customElements.define(...) calls import './styles.css'; // in bundlers that support CSS imports ``` Delete any of those and the program still builds but misbehaves at runtime — missing methods throw `TypeError`, custom elements never upgrade, the UI renders unstyled. Because the failure only appears in the production build, it is an expensive class of bug, which is why bundlers are conservative here by default. The one way such an import *does* get removed is when the package it belongs to explicitly declares it has no side effects (the `sideEffects` field in `package.json`). That is a hand-written claim, not something the analyser verified — the tool trusts it. ## The namespace-import trap ```js import * as utils from './utils.js'; utils.formatDate(d); // still statically analysable: only formatDate is live const fn = utils[name]; // defeats it: any export might be read send(utils); // defeats it: the whole namespace escapes ``` A namespace import is not automatically unshakeable. Modern bundlers track *static property accesses* on the namespace object and keep only those exports. But the moment the namespace is indexed with a computed key, passed to a function, spread, or handed to `Object.keys`, the object has to exist in full, and every export it names becomes reachable. ## Practical rules that follow - Keep imports static and at the top level; a specifier that is a literal is the thing that makes the analysis possible. - Prefer named imports over namespace imports when you use a handful of exports. - Read a bindings-free import as an explicit instruction: "run this module for its effects." If you did not mean that, you wanted a named import. - Do not expect a bundler to prove your top-level code harmless. Unknown means unsafe to it, and it will keep the module.

  • If nothing imports a binding from a module, is the module always dropped from the bundle?
    No. Removing the unused binding and removing the module's evaluation are separate decisions. The module is dropped only if its top level is provably free of side effects, or if its package declares `sideEffects: false`. A module that patches a global, registers a component, or calls an opaque function at the top level is retained even with every one of its exports unused.
  • Does `import * as utils from './utils.js'` automatically prevent tree shaking?
    Not by itself. Bundlers track static property reads such as `utils.formatDate` and keep only those exports. It breaks down when the namespace object has to exist as a real object — a computed access like `utils[name]`, passing `utils` into a function, spreading it, or calling `Object.keys(utils)`. Then every export is potentially read and nothing can be dropped.
  • How many times does a module body run if five different modules import it?
    Once. Module resolution is keyed by resolved specifier, and the module record is cached after its first evaluation; subsequent imports link to the same instance and re-run nothing. That is why a bindings-free import in several files is not a repeated cost, and also why module-level state behaves as a singleton across importers.

saying these in an interview costs you the question

  • Thinks an import with no bindings does nothing at all
  • Believes unused imports are always removed regardless of module contents
  • Assumes tree shaking is part of the ECMAScript specification
  • Says namespace imports always block tree shaking, in every case
  • Confuses removing a binding with skipping the module's evaluation

context