DEV Community

Ashish Kumar
Ashish Kumar

Posted on • Originally published at renderlog.in

Tree Shaking and Code Splitting in JavaScript

A 2MB gzipped JavaScript bundle is ~7MB for V8 to parse and compile. On a mid-range Android at 3G, that is 12 seconds before a single interaction is possible. Bundle size is not an abstract metric — it is directly proportional to Time to Interactive on real hardware.

Why bundle bloat happens silently: Every convenient import adds to the module graph. moment.js locale files, full lodash imports, icon libraries with 1000 icons — none of these produce visible errors. They just make the app slower on devices you don't test on.

What this covers: How bundlers construct the module graph, where tree shaking fails silently, how sideEffects in package.json controls elimination, and how dynamic import() splits the bundle into chunks that load on demand.

Diagram of a bundler pipeline: entry point → module graph → tree shaking → code splitting → output chunks.


What a bundle actually is

When you run npm run build, your bundler (Rollup inside Vite, Webpack, esbuild) does roughly this:

  1. Starting from your entry point (main.tsx), it follows every import statement
  2. It builds a module graph a directed graph of every file that's reachable from the entry
  3. It merges all those files into one or more output files, resolving module boundaries
  4. It applies tree shaking to remove code that's imported but never called
  5. It minifies (removes whitespace and shortens names) and optionally compresses

The default result is one file containing your entire application: every component, every utility, every vendor library. That single file must be downloaded, parsed, and compiled before anything runs.

The reason everything ends up in one file by default is performance optimization for the common case: one large file is often faster than dozens of small ones, because each file requires a separate HTTP request (with its own connection overhead), and the browser's HTTP cache is most effective when file names are stable. One bundle = one cache entry = minimal request overhead.

But "one big file" becomes a liability when:

  • The bundle contains code for routes the user will never visit
  • It contains multiple large vendor libraries that could be split into separate caches
  • Any change to the app (even one line) invalidates the entire bundle cache

This is why code splitting matters.


Bundle analysis: seeing what's actually in there

Before optimizing, you need to understand the bundle's contents. Three tools I actually use:

rollup-plugin-visualizer (for Vite/Rollup projects) generates an interactive treemap:

// vite.config.ts

  plugins: [
    visualizer({
      filename: 'bundle-stats.html',
      gzipSize: true,
      brotliSize: true,
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

After npm run build, open bundle-stats.html. You see a rectangle-packed treemap where each rectangle's area is proportional to the module's size in the bundle. Scan for large vendor rectangles that surprise you. A common discovery is that moment.js is taking 300KB because someone imported it for date formatting once.

webpack-bundle-analyzer does the same for Webpack:

npm install --save-dev webpack-bundle-analyzer
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
Enter fullscreen mode Exit fullscreen mode

source-map-explorer is useful when you have source maps but not a plugin-compatible build setup:

npx source-map-explorer dist/assets/index-abc123.js
Enter fullscreen mode Exit fullscreen mode

What to look for when reading a bundle visualization:

  1. Unexpectedly large single modules often a library you didn't know was that big
  2. Duplicate packages the same library appearing twice at different versions
  3. Code you thought was excluded development utilities or storybook code in production builds
  4. Polyfills for modern browsers if your target browsers support native features, polyfills are dead weight

Tree shaking: how it works and why it fails silently

Tree shaking is the process of eliminating code from the bundle that is imported but never actually called. The term comes from "shaking a tree and letting the dead leaves fall."

Tree shaking relies on ES module static analysis. The key insight: import and export statements are static: the module graph is fully knowable at build time without executing any code.

// ES Module: statically analyzable

// Bundler knows: only formatDate and parseDate are used.
// Any other exports from dates.js can be eliminated.
Enter fullscreen mode Exit fullscreen mode

Compare to CommonJS:

// CommonJS: NOT statically analyzable
const dates = require('./dates.js');
// Bundler cannot know which properties of 'dates' are used at build time.
// require() could be called conditionally, so the entire module must be included.
Enter fullscreen mode Exit fullscreen mode

This is why mixing CommonJS and ES modules causes tree shaking to silently fail. The bundler falls back to including the entire module.


The sideEffects field

Even with ES modules, tree shaking requires one more thing: the sideEffects field in package.json.

A side effect is code that runs when a module is imported, regardless of whether you use its exports. CSS imports, polyfills, global registrations: these are side effects. If the bundler assumes a module has side effects, it can't eliminate it even if none of its exports are used.

// package.json
{
  "name": "my-library",
  "sideEffects": false
}
Enter fullscreen mode Exit fullscreen mode

"sideEffects": false tells bundlers: "every file in this package is pure. If nothing imports from it, it can be eliminated." Without this, even unused modules from a dependency are included in the bundle.

For your own application code, you can be more precise:

{
  "sideEffects": ["*.css", "src/polyfills.js"]
}
Enter fullscreen mode Exit fullscreen mode

This says: CSS files and the polyfills file have side effects (they must be included), but all other JavaScript files are pure. The bundler can tree-shake the JavaScript while preserving the CSS imports.


Common tree shaking failures

Barrel files

A barrel file is an index.js that re-exports everything from a directory:

// components/index.js (barrel file)

// ... 50 more exports
Enter fullscreen mode Exit fullscreen mode
// Using it

Enter fullscreen mode Exit fullscreen mode

The problem: some bundlers (particularly older Webpack configurations and certain Rollup setups) cannot tree-shake barrel files reliably. When you import Button from the barrel, the bundler may include the entire barrel all 50+ components because it can't prove the barrel itself doesn't have side effects from the re-export pattern.

The fix is to import directly from the source file:


Enter fullscreen mode Exit fullscreen mode

Or configure your bundler explicitly to handle barrels. Vite handles this well in modern versions, but it's worth verifying with the bundle analyzer that barrel imports aren't inflating your output.

lodash

Importing lodash like this includes the entire 70KB library:


Enter fullscreen mode Exit fullscreen mode

The fix is either lodash-es (which uses ES modules and tree-shakes correctly) or direct cherry-picking:


// or

Enter fullscreen mode Exit fullscreen mode

moment.js locale problem

moment.js has a notorious bundling issue: it includes all locale files by default. If you're using moment at all, you're pulling in ~300KB of locale data for languages you'll never use.

// This pulls in ALL locales: ~300KB gzipped

Enter fullscreen mode Exit fullscreen mode

The options, ranked by recommendation:

  1. Replace with date-fns tree-shakeable, only includes what you import
  2. Replace with dayjs 2KB gzipped, locale files are separate optional imports
  3. Use Webpack IgnorePlugin to exclude locale files from moment (if migration is not feasible)

Code splitting

Code splitting is the practice of splitting your bundle into multiple files that load on demand. Instead of one 2MB bundle, you might have:

  • A 200KB core bundle that loads immediately
  • A 400KB dashboard chunk that loads when the user navigates to /dashboard
  • A 100KB admin chunk that loads only if the user is an admin

The result: users only download code for the features they actually use.

React.lazy() and Suspense

The canonical React code splitting pattern:


// Instead of: import Dashboard from './Dashboard';
const Dashboard = lazy(() => import('./Dashboard'));
const AdminPanel = lazy(() => import('./AdminPanel'));

function App() {
  return (
    }>

    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

React.lazy() takes a function that returns a dynamic import(). The bundler sees the dynamic import and automatically creates a separate chunk for Dashboard.tsx and all its unique dependencies. That chunk is not downloaded until the user navigates to /dashboard.

The Suspense boundary shows `` while the chunk is downloading. For route-level splits, this is typically the loading spinner or skeleton you'd show during data fetching anyway.

Dynamic import() and chunk naming

Under the hood, lazy() uses dynamic import(). You can use it directly for non-component code:

`js
// Loads only when called, not at app startup
async function processSpreadsheet(file) {
const { read, utils } = await import('xlsx'); // 400KB library, only loaded when needed
const workbook = read(await file.arrayBuffer());
return utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
}
`

Magic comments let you control chunk names:

`js
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */
/* vite: { chunkName: "dashboard" } */
'./Dashboard'
));
`

Without magic comments, bundlers generate hash-based names like chunk-abc123.js. Named chunks make your build output readable and help with debugging production issues.


Vendor splitting for cache utilization

Libraries like React, React DOM, and React Router change infrequently, maybe once every few months when you upgrade. Your application code changes with every deployment.

If they're all in one bundle, every deployment invalidates the browser's cache for React even though React itself hasn't changed.

Vendor splitting keeps stable libraries in separate chunks with content-hash filenames:

`js
// vite.config.ts

build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-query': ['@tanstack/react-query'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
}
}
}
}
};
`

Now vendor-react-[hash].js stays cached across deployments. Users who visited yesterday already have React cached. They only download your application code when you deploy.

Bundle type Changes on every deploy? Cache lifetime
App code Yes Short (invalidated on every deploy)
Vendor (React etc.) No (until you upgrade) Long (months between upgrades)
Feature chunks Only when that feature changes Medium

The entry point waterfall

One anti-pattern that's easy to overlook: eagerly importing all routes in the entry point.

`js
// main.tsx (WRONG): eager imports defeat code splitting

`

Even if you use React Router to only render one route at a time, Webpack/Vite will see these static imports and include all pages in the initial bundle. The dynamic import pattern only works when import() is actually dynamic:

`js
// main.tsx (RIGHT): all routes are lazily loaded
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
const Reports = lazy(() => import('./pages/Reports'));
`

Check your bundle visualizer. If all your route components appear in the main chunk, this is why.


Measuring bundle impact in CI

Ad-hoc bundle analysis after the fact is better than nothing, but the real win is preventing bundle bloat in CI before it reaches production.

size-limit is a package that fails your CI build if the bundle exceeds defined limits:

`json
// package.json
{
"size-limit": [
{
"path": "dist/assets/index-*.js",
"limit": "300 KB",
"gzip": true
},
{
"path": "dist/assets/vendor-react-*.js",
"limit": "150 KB",
"gzip": true
}
]
}
`

`yaml

.github/workflows/build.yml

  • name: Check bundle size run: npx size-limit `

With this in place, a PR that inadvertently adds a 500KB dependency (say, someone imports moment instead of date-fns) will fail CI with a clear error message:

`
dist/assets/index-abc123.js
Size: 487 KB with all dependencies, minified and gzipped

Package size limit has exceeded the limit.
Size limit: 300 KB
Size: 487 KB
Try to reduce size or increase the limit.
`

Performance budgets go beyond just JS size. Lighthouse CI lets you set budgets on LCP, TTI, and total transfer size:

`json
// lighthouserc.js
module.exports = {
assert: {
assertions: {
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
'interactive': ['error', { maxNumericValue: 5000 }],
'total-byte-weight': ['error', { maxNumericValue: 1000000 }],
}
}
};
`


The bundle optimization checklist

Step Tool Expected win
Analyze current bundle rollup-plugin-visualizer Identify large dependencies
Find duplicate packages npm dedupe, bundlesize Eliminate redundant copies
Fix lodash imports lodash-es or cherry-pick Save 50–70KB
Add sideEffects: false package.json Enable tree shaking for your code
Route-level code splitting React.lazy() Defer non-critical routes
Vendor splitting Rollup manualChunks Improve cache utilization
Dynamic import for heavy libs import() Only load when needed
Set size limits in CI size-limit Prevent future regressions

Bundles don't bloat all at once. They bloat incrementally — one convenient import at a time. The defense is measurement, budgets, and a clear understanding of what each dependency actually costs to download, parse, and compile on real hardware.


Read the original article on Renderlog.in:
https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/

If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:

JSON Tools: https://json.renderlog.in
Text Tools: https://text.renderlog.in
QR Tools: https://qr.renderlog.in

Top comments (0)