Package Size Optimization and Tree Shaking 2026
TL;DR
Bundle size directly affects web application performance — every kilobyte costs load time. Tree shaking eliminates unused code from bundles, but it only works with ESM's static import statements, not CommonJS require(). The classic example: importing a single function from moment.js includes the entire 67KB library because moment uses CJS exports. In 2026, package authors who want consumers to get optimal bundle sizes must publish ESM with named exports and set "sideEffects": false. Package consumers must measure, set size budgets with size-limit, and reach for tree-shakeable alternatives to legacy CJS-only packages.
Key Takeaways
- Tree shaking requires ESM (
import/export) — CJS (require()) cannot be statically analyzed for dead code elimination "sideEffects": falseinpackage.jsontells bundlers the package has no side effects and unused exports can be eliminated- Named exports (
export function foo()) are tree-shakeable; default export of an object (export default { foo, bar }) is not - moment.js is the canonical example of an untree-shakeable CJS package — switch to
date-fnsorTemporalinstead size-limitadds CI checks that fail when bundle size exceeds a threshold- Webpack Bundle Analyzer and Rollup Plugin Visualizer show which packages dominate your bundle
- Bundlephobia checks a package's size and tree-shakeable status before you install it
Why Package Size Matters
JavaScript bundle size affects three concrete metrics:
Initial load time. Every kilobyte of JavaScript must be downloaded, parsed, and compiled before the page can be interactive. On a median mobile connection, 100KB takes roughly 2 seconds to download and parse. The browser doesn't start executing your application logic until this is complete.
Core Web Vitals. Google's Largest Contentful Paint (LCP) and Interaction to Next Paint (INP) metrics — which factor into search ranking — are directly affected by JavaScript bundle size. A 500KB bundle on a slow connection will fail LCP targets.
Memory usage. Parsed JavaScript stays in memory. Large bundles mean higher memory pressure, which causes garbage collection pauses and sluggish interactions on memory-constrained mobile devices.
These costs compound. A dependency that adds 50KB to your bundle might be the difference between a 3G user experiencing your app as fast or unusable.
How Tree Shaking Works
Tree shaking is the process by which bundlers (Webpack, Rollup, esbuild, Parcel) remove unused exports from the final bundle. The mechanism depends on static analysis of import statements.
ESM Enables Tree Shaking
ESM's import and export statements are static — they appear at the top level of a module, not inside functions or conditionals. A bundler can analyze them at build time without executing the code.
// library.js — ESM
export function used() { return 'needed'; }
export function unused() { return 'not needed'; }
export function alsoUnused() { return 'eliminated'; }
// app.js — consumer
import { used } from './library.js';
console.log(used());
A bundler sees that only used is imported. It includes used in the bundle and eliminates unused and alsoUnused. The final bundle contains only used's code.
CJS Prevents Tree Shaking
CommonJS exports are evaluated at runtime, not statically analyzable at build time.
// library.js — CJS
module.exports = {
used: function() { return 'needed'; },
unused: function() { return 'not needed'; },
alsoUnused: function() { return 'eliminated? no.' }
};
// app.js — consumer
const { used } = require('./library');
The bundler sees require('./library') and must include the entire module — it can't know at build time which properties of module.exports will be accessed. The entire object, including unused and alsoUnused, ends up in the bundle.
The sideEffects Field
Even with ESM, bundlers need a signal that unused modules are safe to eliminate. Some modules have side effects when imported: CSS imports that inject styles, polyfills that modify global objects, analytics that initialize on import.
The "sideEffects" field in package.json tells bundlers whether the package can be safely tree-shaken:
{
"name": "my-package",
"sideEffects": false
}
"sideEffects": false is a contract: importing any module from this package doesn't cause side effects. Bundlers use this to eliminate entire modules (not just functions) when none of their exports are used.
If your package does have side-effectful files (CSS, polyfills, certain initialization code), list only those:
{
"sideEffects": [
"*.css",
"./src/polyfill.js",
"./src/global-init.js"
]
}
All other files are treated as side-effect-free and tree-shaken aggressively.
For packages that don't set "sideEffects", bundlers default to assuming everything has side effects and skip tree shaking. This is why many packages import as large chunks even when you only use a small portion.
Named Exports vs Default Exports
The export style matters for tree shaking granularity.
Named exports (tree-shakeable)
// package — named exports
export function parseDate(str) { /* ... */ }
export function formatDate(date) { /* ... */ }
export function addDays(date, n) { /* ... */ }
export function differenceInDays(a, b) { /* ... */ }
// consumer — only parseDate and formatDate end up in bundle
import { parseDate, formatDate } from 'my-date-lib';
The bundler sees exactly which exports are used and eliminates the rest.
Default export of an object (not tree-shakeable)
// package — default export of object
export default {
parseDate: function(str) { /* ... */ },
formatDate: function(date) { /* ... */ },
addDays: function(date, n) { /* ... */ },
differenceInDays: function(a, b) { /* ... */ }
};
// consumer — entire object is included, even unused methods
import DateLib from 'my-date-lib';
const formatted = DateLib.formatDate(new Date());
The bundler sees the entire DateLib object is imported as a reference. It can't know at build time which methods will be called. All methods end up in the bundle.
Use named exports for library packages. Default exports are fine for values (a config object, a class, a single function), but exporting a namespace object of utility functions defeats tree shaking.
The moment.js Problem
moment.js is the canonical case study for untree-shakeable packages. It's one of the most widely used JavaScript date libraries, but it adds 67KB gzipped to any bundle that imports it — even if you only use moment().format('YYYY-MM-DD').
The reasons moment.js can't be tree-shaken:
- CJS internals. Moment's core logic uses CommonJS modules internally; bundlers can't fully tree-shake CJS dependency trees.
- Locale bundling. Moment bundles all locale files (77 locales) into the main module. Every locale is included unless you specifically configure your bundler to exclude them.
- Object-based API. Moment's API is a fluent interface on a moment object — bundlers can't know which methods you'll call.
The modern alternatives:
date-fns: ESM-native with named exports. Import only the functions you use:
// Only these two functions in the bundle
import { format, addDays } from 'date-fns';
const next = format(addDays(new Date(), 7), 'yyyy-MM-dd');
Temporal (TC39 Proposal): The JavaScript language standard for date/time, shipping in Node.js 26 / major browsers 2025-2026. Zero bundle size — it's a language built-in.
Luxon: ESM-only, tree-shakeable, modern API.
The switch from moment to date-fns typically saves 50-70KB in gzipped bundle size — a significant improvement for any application.
Measuring Bundle Size
Webpack Bundle Analyzer
For Webpack-based projects (Next.js, Create React App, custom Webpack):
npm install --save-dev webpack-bundle-analyzer
# In webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [new BundleAnalyzerPlugin()]
};
Generates an interactive treemap showing every module's contribution to the bundle. Click into a package to see which internal modules it includes.
For Next.js specifically:
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
});
module.exports = withBundleAnalyzer({});
ANALYZE=true npm run build
Rollup Plugin Visualizer
For Rollup-based builds (Vite, many library setups):
npm install --save-dev rollup-plugin-visualizer
// vite.config.js or rollup.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true, gzipSize: true })
]
};
Produces an interactive visualization after each build showing package size contributions.
Bundlephobia
Before adding a new dependency, check its bundle size on Bundlephobia:
# Not an npm package — visit in browser
# https://bundlephobia.com/package/package-name
# Or check via npm pack --dry-run for your own packages
npm pack --dry-run
Bundlephobia shows:
- Minified and gzipped size
- Whether the package supports tree shaking
- Download time on 3G/4G
- Similar but smaller alternatives
Making bundle size research a habit before adding dependencies prevents gradual bundle bloat from accumulating.
size-limit: CI Bundle Size Budgets
size-limit adds automated bundle size checks to your CI pipeline. Set a budget; CI fails when you exceed it:
npm install --save-dev size-limit @size-limit/preset-big-lib
// package.json
{
"size-limit": [
{
"path": "dist/index.js",
"limit": "10 KB"
},
{
"path": "dist/index.cjs",
"limit": "12 KB"
}
],
"scripts": {
"size": "size-limit",
"size:check": "size-limit --why"
}
}
# CI integration
- name: Check bundle size
run: npm run size
size-limit uses rollup or webpack under the hood to simulate how a consumer would bundle the package. The measurement reflects real consumer bundle impact, not just the raw file size.
The --why flag uses webpack-bundle-analyzer to show why the bundle is the size it is — useful when you hit the limit and need to investigate.
Practical Optimization Techniques
Code Splitting
For application bundles (not library packages), dynamic imports split code across multiple chunks loaded on demand:
// Load heavy dependency only when needed
const { heavyFeature } = await import('./heavy-feature.js');
Next.js and Vite do this automatically for route-based splitting. Manual dynamic imports are for features that aren't always needed.
peerDependencies for Shared Libraries
For library packages, declare React, Vue, and other large libraries as peerDependencies, not dependencies:
{
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"react": "^18.0.0"
}
}
Declaring React as a dependency would bundle it into your library — every consumer would get a second copy of React. As a peerDependency, your library uses the React instance the consumer already has. This is especially important for React hooks, which break when two React instances are present.
For the complete list of what to include in dependencies vs devDependencies vs peerDependencies, see Publishing an npm Package: Complete Guide 2026.
Subpath Imports for Granular Consumption
For large packages with distinct feature areas, expose subpath imports so consumers can import only what they need:
// package.json
{
"exports": {
".": "./dist/index.js",
"./parser": "./dist/parser.js",
"./formatter": "./dist/formatter.js",
"./utils": "./dist/utils.js"
}
}
// Consumer only pulls in parser module
import { parse } from 'my-package/parser';
// formatter and utils are never loaded
Libraries like date-fns, lodash-es, and @radix-ui/* use this pattern. It gives consumers granular control even for packages that can't be fully tree-shaken at the function level.
ESM, Tree Shaking, and Library Authors
Package authors in 2026 have a clear responsibility to their consumers: publish ESM with named exports and "sideEffects": false. The bundle size impact your package has on consumers' applications is partly your design decision.
Checklist for tree-shakeable packages:
-
"type": "module"inpackage.json(or.mjsoutput files) -
"exports"field using"import"and"require"conditions - Named exports for all public API functions
-
"sideEffects": false(or explicit list of side-effectful files) - No re-exports that import entire modules:
export * from './everything'with large modules can defeat tree shaking - Large optional features behind subpath exports
For the migration path from CommonJS to ESM including the syntax changes and __dirname replacements, see ESM Migration: CommonJS to ESM 2026.
Common Bundle Bloat Sources
Beyond individual packages, these patterns cause bundle size to grow faster than features:
Importing entire utility libraries. import _ from 'lodash' includes all of lodash. Use lodash-es with named imports or individual lodash.function packages instead.
Date libraries. moment.js, as discussed. Use date-fns or the Temporal API.
Full icon libraries. import { IconA, IconB } from '@mui/icons-material' — that directory has thousands of icons. Tree shaking works, but the sheer number of modules slows builds. Consider direct path imports: import IconA from '@mui/icons-material/IconA'.
Polyfill bundles. Including core-js/stable in full adds 100KB+. Target specific polyfills for your supported browsers only.
Unused translations. i18n libraries often bundle all supported locales. Import only the locales your application uses.
Development-only code in production bundles. Ensure tools like Redux DevTools, React DevTools, and Storybook components are excluded from production builds with process.env.NODE_ENV guards.
Methodology
This article draws on:
- Webpack documentation on tree shaking and
sideEffects - Rollup documentation on tree shaking
- Bundlephobia bundle size database
- size-limit documentation and GitHub
- date-fns documentation (as tree-shakeable moment.js alternative)
- Google Web.dev documentation on Core Web Vitals and JavaScript performance
- TC39 Temporal proposal documentation
- Webpack Bundle Analyzer and Rollup Plugin Visualizer documentation
- HTTP Archive Web Almanac 2025 — JavaScript chapter (bundle size statistics)