Fixing Tree Shaking Issues with Lodash and Moment
When a bundle ships the entire lodash library for three helper calls, or drags in 160KB of moment locales nobody renders, you are looking at a CommonJS static-analysis failure. This is the concrete instance of the broader workflow in tree shaking and dead code elimination, itself part of JavaScript bundle optimization and code splitting. The fix is targeted and measurable: clear the barriers and these two dependencies drop by 40% or more in gzipped transfer.
Modern bundlers cannot statically analyze legacy CommonJS packages without explicit configuration. Below is a rapid diagnosis checklist, the named failure modes behind the bloat, paste-ready fixes ordered by impact, and the verification steps that prove the reduction held.
Rapid Diagnosis: A DevTools and Analyzer Checklist
Before changing anything, confirm the symptom and locate the bytes:
- Run
npx webpack-bundle-analyzer dist(orrollup-plugin-visualizer) and filter the treemap to thelodashandmomentdirectories. A monolithic single block means tree shaking never engaged. - Open the Chrome DevTools Coverage tab (
Ctrl+Shift+P→Show Coverage), reload, and read the unused percentage for each utility file. Above 60% unused onlodashconfirms full-library inclusion. - Audit each dependency's
package.jsonfor a"module"field versus only"main". No"module"entry means npm is resolving the CommonJS build. - Verify the bundler runs in
productionmode — development builds skip minification and most pruning.
Baseline footprint (gzipped): default lodash ~24KB (full library; ~72KB raw), and moment core ~18KB with all locales adding ~160KB. Targets after the fix: unused lodash/moment code under 5%, total utility contribution under 20KB gzipped, and main-thread parse time for these modules under 100ms.
Root Cause Analysis: Four Named Failure Modes
1. CommonJS monolithic exports. The default lodash and moment packages ship as single CommonJS modules using module.exports. Tree shaking relies on static import/export syntax to map a dependency graph; CJS exposes one runtime object, so the bundler cannot prove any property is unused and keeps the whole thing.
2. Namespace imports. Even against an ESM build, import _ from 'lodash' or import * as _ from 'lodash-es' binds the entire namespace. Dynamic property access (_[methodName]) defeats static analysis the same way CommonJS does — the bundler cannot know which members run. Why static structure matters across module systems is detailed in modern module formats: ESM vs CommonJS.
3. Moment's dynamic locale loading. Moment loads locales through a dynamic require() context (moment/locale/*) that bundlers resolve by including the entire directory. This is structurally unfixable by tree shaking alone — it needs a context restriction.
4. Missing or wrong sideEffects. Without "sideEffects": false (or an accurate array) in your manifest, the bundler assumes every import carries observable side effects and skips elimination even when the imports are pure.
Step-by-Step Resolution, Ordered by Impact
1. Replace lodash with named lodash-es imports
lodash-es re-exports every function as a named ES module export, restoring static analysis. Switch namespace imports to named imports:
// FAILS TREE SHAKING: CJS monolithic import pulls the whole library.
import _ from 'lodash';
const result = _.debounce(fn, 300);
// ENABLES TREE SHAKING: named ESM import keeps only debounce.
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);
// trade-off: lodash-es is ESM-only, so a CommonJS consumer (an old Jest
// transform, a require()-based script) will fail to load it — keep the CJS
// lodash there or add an ESM transform before migrating those entry points.
Expected outcome: for 3-5 utilities, lodash contribution drops from ~24KB to under 5KB gzipped, eliminating roughly 19KB of transfer and ~80ms of parse on a mid-tier device.
2. Set an accurate sideEffects contract
Tell the bundler these utility modules are pure so it may eliminate the unused ones:
{
"name": "my-app",
"version": "1.0.0",
"sideEffects": false,
"dependencies": {
"lodash-es": "^4.17.21"
}
}
// trade-off: "sideEffects": false is safe for a pure utility-only app, but if
// your own source injects CSS or registers polyfills via import, a blanket
// false will strip those — use the array form listing "*.css" and any
// side-effectful setup file instead.
Expected outcome: unblocks elimination of unused lodash members that survived step 1, typically pushing unused lodash bytes to 0% in the final bundle.
3. Restrict moment locales with context replacement
Moment cannot be tree-shaken at its core, but you can strip every locale you do not render:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
// ...
plugins: [
new webpack.ContextReplacementPlugin(
/moment[\\/]locale$/,
/^\.\/en$/
)
]
};
// trade-off: this hard-restricts available locales to English — if you later
// localize dates for another region, that locale silently won't load at
// runtime, so widen the regex (e.g. /^\.\/(en|de|fr)$/) when you add languages.
For Vite, alias and pre-bundle instead:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
lodash: 'lodash-es'
}
},
optimizeDeps: {
include: ['lodash-es']
}
});
// trade-off: the lodash->lodash-es alias rewrites every transitive import too,
// so a dependency that genuinely needs the CommonJS lodash build can break —
// scope the alias or vet your dependency tree before shipping.
Expected outcome: moment core plus the en locale drops to ~5KB gzipped, removing the ~160KB locale payload entirely.
4. Migrate off moment where feasible
For new code, date-fns (tree-shakable by design) or dayjs (~2KB core) replace moment with full ESM exports, so the bundler includes only the functions you call. Expected outcome: typical date logic falls from ~18KB to 2-4KB gzipped with no locale directory to manage.
Verification: Before/After, CI Assertion, and Field Check
Prove the reduction held rather than assuming it. Re-run the analyzer and diff the treemap nodes — lodash-es and moment should now show only the functions you import. Cross-check the Coverage tab for under 5% unused on these modules.
Then lock it in CI so a stray import _ from 'lodash' cannot reintroduce the bloat:
// .bundlesize / CI assertion (conceptual)
module.exports = {
files: [
{ path: 'dist/vendor.*.js', maxSize: '150 kB' }
]
};
// trade-off: a vendor-wide budget catches regressions but not which dependency
// caused them — pair it with an ESLint no-restricted-imports rule on the bare
// "lodash" and "moment" names so the offending import fails review directly.
Add an ESLint no-restricted-imports rule banning the bare lodash and moment package names (tolerance: 0 occurrences in pull requests), and confirm via Real User Monitoring that the initial route's JS transfer stays under 150KB gzipped and that field LCP holds under 2.5s after deploy.
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
Full namespace imports (import _ from 'lodash') | Whole library bundled regardless of usage. | Named imports from lodash-es, or babel-plugin-lodash. |
Omitting sideEffects in package.json | Bundler assumes side effects and skips elimination. | Add "sideEffects": false or an accurate array. |
| Ignoring moment locale bloat | All locale files bundle, adding ~160KB. | ContextReplacementPlugin to restrict active locales. |
| Mixing CJS and ESM in one graph | Duplicate modules or skipped optimization. | Standardize on ESM, set mainFields: ['module', 'main']. |
Related
- Tree shaking and dead code elimination is the full diagnostic workflow these package-specific fixes plug into.
- JavaScript bundle optimization and code splitting sets the broader payload and splitting strategy.
- Modern module formats: ESM vs CommonJS explains the static-analysis difference that makes lodash-es work where lodash does not.
- Webpack bundle analysis techniques shows how to attribute the residual bytes after the migration.