skip to content

Tree Shaking and Dead Code

Tree shaking only works if your code and your dependencies allow it. A favourite interview question is why an import you "barely use" still added tens of kilobytes.

on this pageshow

questions

4

In a bundled web app you add `import { debounce } from 'lodash'` for one small helper, and the production bundle grows by roughly 70 kB even though the build has tree shaking enabled. How do you work out why, and what are your options for getting those bytes back?

level: middleimportance: must knowfreq 62%

answer

  1. the import line is not the cost
  2. ask what the package actually publishes
  3. module format decides what can be dropped
  4. CommonJS main build versus lodash-es
  5. deep import path, or no dependency

basics

~20 s

Tree shaking cannot narrow what the module format hides: lodash's main published build is CommonJS, so a named import still drags in the whole library. Confirm the size in the build output, then deep-import lodash/debounce, switch to lodash-es, or drop the dependency.

solid answer

~50 s

First I confirm the number is real by looking at what the production build actually contains for that entry — 70 kB attributed to `lodash` is a strong signal on its own. The cause is almost always the distribution you are consuming, not your import syntax: lodash's main published build is CommonJS with heavily cross-referenced internals, so the build cannot prove that only `debounce` is reachable and keeps the module whole. Named-import syntax does not force elimination; the package has to be shaped so elimination is possible. The cheap remedies, in order: deep-import the single module with `import debounce from 'lodash/debounce'`, or move the dependency to `lodash-es`, which publishes ES modules. The better question is whether the dependency earns its place at all — a debounce is a dozen lines. Whatever I choose, I re-measure the built output, because bytes are the only proof.

code

javascript · 10 lines
javascript
// Whole CommonJS build of lodash lands in the bundle
import { debounce } from 'lodash';

// Only the debounce module and its internal dependencies
import debounceOnly from 'lodash/debounce';

// ES-module distribution of the same functions
import { debounce as debounceEs } from 'lodash-es';

console.log(typeof debounce, typeof debounceOnly, typeof debounceEs);

go deeper

for a junior

Know that importing one function from a library does not always cost one function's worth of bytes, and that import debounce from 'lodash/debounce' is the cheaper form to write.

for a middle

Be ready to explain that elimination needs a statically analysable module graph, that a CommonJS distribution does not provide one, and to name the deep-import and ES-module-build remedies concretely.

for a senior

Show the diagnosis order: attribute the bytes in the built output first, then fix, then re-measure. Mention duplicate copies from transitive dependencies, and the lint plus size-check guard that stops the regression recurring.

for a principal

Own the policy question: which dependencies the codebase is allowed to take, what evidence of shakeability is required before adoption, and when paying the bytes is the right call rather than a failure.

## The situation, stated precisely You wrote one import line for one function. The production bundle for that entry got tens of kilobytes bigger. Tree shaking — the build step that drops code nothing can reach — is switched on. Nothing is broken, and nothing is misconfigured. The bundle is telling you something true about the package you just adopted. The mental model that fails here is "tree shaking removes what I don't use." The accurate model is narrower: a build can only remove code it can *prove* is unreachable. Proof requires a statically analysable module graph — imports and exports that are fixed at build time. Where the graph is opaque, the build keeps everything, because keeping working code is always the safe choice and dropping reachable code would break the app. ## Step one is attribution, not a fix Before changing anything, find out which module owns the bytes. Every serious build can emit a report of what ended up in each output file, and the answer to "why did my bundle grow" is usually one line of that report. Attribute first, because the same 70 kB symptom has several different causes and the remedy depends on which one you have: - the package ships a non-shakeable distribution (the lodash case); - you imported through an aggregating entry point that pulls in far more than the one function you wanted; - the helper you imported transitively depends on something large — a date library, a polyfill, an internationalisation table; - you now have two copies of the same library at different versions, one from your import and one from a transitive dependency. Guessing between these wastes a day. Reading the output takes a minute. ## Which distribution are you consuming For lodash specifically, the package published as `lodash` is a CommonJS build whose modules reference each other through shared internals. A CommonJS module's exports are an object assembled at runtime, so a build tool cannot generally decide that only one property of it is ever read. The result is that `import { debounce } from 'lodash'` and `const _ = require('lodash')` cost roughly the same. The same library also publishes `lodash-es`, an ES-module build of the same functions. ES modules declare their imports and exports statically, which is the precondition that makes elimination possible in the first place. Packages also carry a `sideEffects` field in package.json that further influences what a build dares to drop — but no field rescues you when the module format itself is opaque. The practical habit: before adopting a dependency, look at what it publishes. A package that ships an ES-module build, keeps its functions in separate modules, and has few transitive dependencies will cost you close to what you use. A package that ships one monolithic CommonJS file will cost you all of it, forever, no matter how disciplined your import lines are. ## The remedies, cheapest first ```javascript // Whole CommonJS build lands in the bundle import { debounce } from 'lodash'; // Only the debounce module and its internal dependencies import debounce from 'lodash/debounce'; // ES-module distribution: elimination is possible import { debounce as debounceEs } from 'lodash-es'; ``` 1. **Deep import the submodule.** Works today, needs no dependency change, and is enforceable with a lint rule that bans the root import. 2. **Switch the distribution.** Moving to `lodash-es` fixes it at the source, but check that no transitive dependency still pulls the CommonJS copy — otherwise you ship both. 3. **Replace or inline.** One helper rarely justifies a dependency. A debounce, a `groupBy`, a deep clone are each small enough to own, and several now have platform equivalents. 4. **Accept it deliberately.** If the library really is load-bearing, record that the bytes are a known, argued-for cost rather than an accident. ## Why syntax alone is not the answer Candidates often reach for "use named imports" as the whole fix. Named imports are a precondition on *your* side, and they matter — but they do nothing if the module on the other side of the import cannot be split apart. Likewise minification is not elimination: a minifier renames identifiers and squeezes syntax in the code that is kept, while deciding a module is unreachable happens earlier, over the module graph. ## What a good answer sounds like Measure, attribute, name the module format as the likely cause, give a concrete remedy with a concrete import path, and then re-measure. The last step is the one weak answers skip, and it is the only part that proves the bytes actually went away.

  • You switch the app to lodash-es and the bundle barely shrinks. What would you look for?
    Almost certainly a second copy. A transitive dependency is still requiring the CommonJS `lodash`, so the build now contains both distributions. I would trace which package pulls it in from the dependency tree, and either upgrade that package, deduplicate the version, or go back to deep imports so at least my own code is not adding a third copy.
  • The package you need only ever ships CommonJS. What are your options then?
    Import the single file you need if the package is split into files, since deep imports often work even in CommonJS builds. Otherwise: replace the library, extract the handful of functions you use into your own module if the licence allows, or accept the cost explicitly. I would not spend a week making an unshakeable package shakeable for a helper I could write.
  • How would you stop the same regression from being merged again next month?
    Two cheap guards. A lint rule that forbids importing the package's root entry, so the expensive form fails review automatically, and a size check on the built output for the affected entry, so a regression shows up as a failing build rather than as a user complaint. Documentation alone does not survive a team's turnover.

Tree shaking is like removing unread books from a shipment: easy when each book is a separate parcel with a label, impossible when the whole library arrived shrink-wrapped as one crate.

saying these in an interview costs you the question

  • Claims tree shaking removes anything unused, unconditionally
  • Blames the bundler configuration instead of the package format
  • Thinks named-import syntax alone guarantees elimination
  • Says minification will strip the unused functions anyway
  • Adds a second utility library to fix the first one's size

context

open as a page

In a bundled ES-module app, does writing `import * as utils from './utils.js'` and calling only `utils.formatDate(d)` cost more bytes than `import { formatDate } from './utils.js'`?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Usually not: a modern bundler can see that only formatDate is read off the namespace and drops the rest. It costs bytes the moment the namespace object escapes — passed to a function, spread, or indexed with a variable — because then every export must be kept.

open as a page

A feature flag was fully rolled out six months ago, yet both branches of `if (flags.newCheckout) { ... } else { ... }` are still in the production bundle. What determines whether a build can delete the dead branch, and what would you change?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Dead-code elimination is static: a branch is removed only when the condition folds to a constant at build time. A flag read from runtime configuration is opaque, so both branches ship. The real fix is retiring the flag and deleting the code, not making the build smarter.

open as a page

A charting dependency your product relies on publishes only a CommonJS build and accounts for a large share of your main bundle. As the person making the call, how do you weigh working around it, forking it, replacing it, or simply accepting the bytes?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Decide by who pays and what the alternatives cost, not by whether the bytes offend you. Establish how many users actually reach the feature, try deep imports first, price a replacement's migration honestly, and treat forking as taking on permanent maintenance.

open as a page