Tree Shaking and Code Splitting in Webpack

Learn how to eliminate dead code and deliver optimized JavaScript bundles that load faster. A TypeScript-first guide to modern build optimization.

Modern web applications often ship far more JavaScript than users actually need. Tree shaking and code splitting are complementary optimization techniques that work together to deliver only the code your application uses, exactly when it's needed. For TypeScript developers, understanding these techniques is essential for building lean, performant applications that provide exceptional user experiences.

What is Tree Shaking?

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It refers to the process of removing unused code from your final bundle, resulting in smaller file sizes and faster load times. The name derives from the mental image of shaking a dependency tree to make dead leaves (unused code) fall out.

Tree shaking relies on the static structure of ES2015 module syntax--specifically the import and export statements. Bundlers like Webpack and Rollup analyze the dependency graph to identify unused exports and remove them from the final bundle. This means only the code your application actually uses gets shipped to production, without changing your runtime behavior or requiring code changes.

The concept originated with Rollup.js and has since been adopted by all modern JavaScript bundlers. When you import only what you need from a library, tree shaking ensures that the rest of that library never makes it into your bundle.

How Tree Shaking Works

Tree shaking works through modern JavaScript bundlers that examine the codebase, ascertain the dependencies, and remove unused exports during the build process. This process is particularly effective with ES6 modules, which allow for static analysis at build time.

The tree shaking process follows these key steps:

  1. Static Analysis: The bundler parses all import and export statements without executing the code
  2. Dependency Graph: A graph is built showing which modules export what and which exports are actually used
  3. Marking Unused Code: Exports that are never imported anywhere are marked for elimination
  4. Minification: The minifier (typically Terser) removes the marked code from the output

This static analysis is only possible because ES modules have a fixed structure. Unlike CommonJS modules where require() can be called conditionally, ES module imports are resolved at build time.

The Role of ES Modules

ES modules provide the static structure that makes tree shaking possible. Unlike CommonJS modules (require/module.exports), ES modules have a fixed structure that can be analyzed without executing the code. This static analysis allows bundlers to determine exactly which exports are used and which can be safely removed.

Key ES module characteristics that enable tree shaking:

  • Static imports: import and export statements are hoisted and cannot be conditional
  • Read-only live bindings: Imports are references to exported values, not copies
  • Explicit dependencies: The dependency graph is fully determinable at build time
  • No circular issues: ES modules handle circular dependencies gracefully

Tree shaking relies on the static structure of ES2015 module syntax

Tree Shaking Fundamentals in Webpack

Webpack provides built-in support for tree shaking through the usedExports optimization option and the sideEffects configuration in package.json. Understanding these mechanisms is crucial for maximizing the effectiveness of dead code elimination in your projects.

Webpack 4 and later versions have significantly improved tree shaking capabilities. When you build in production mode, many optimizations are enabled automatically. However, understanding the underlying configuration options helps you troubleshoot issues and optimize your setup further. For a broader perspective on modern build tooling alternatives, see our guide on Vite and the future of frontend tooling.

Configuration Requirements

For effective tree shaking, webpack requires specific configuration:

module.exports = {
 optimization: {
 usedExports: true,
 sideEffects: true,
 },
 mode: 'production',
};

The usedExports option adds comment markers to indicate which exports are unused. The minifier (Terser) then removes the marked code from the output. Production mode automatically enables these optimizations along with additional minification and compression.

In development mode, these optimizations are typically disabled to improve build times and preserve debugging capabilities. Always test your production builds to verify that tree shaking is working correctly.

Marking Files as Side-Effect-Free

The sideEffects property in package.json tells bundlers whether a module has side effects. A "side effect" is code that performs special behavior when imported, other than exposing one or more exports. Polyfills are a common example--they modify the global scope when imported.

Declare no side effects:

{
 "name": "your-project",
 "sideEffects": false
}

Specify which files have side effects:

{
 "name": "your-project",
 "sideEffects": [
 "**/*.css",
 "./src/polyfills.ts"
 ]
}

If a module is marked as side-effect-free and none of its exports are used, the bundler can skip evaluating the module entirely--including all of its dependencies. This makes sideEffects: false one of the most powerful optimizations available.

usedExports vs sideEffects

Understanding the difference between these two optimization approaches

usedExports (Tree Marking)

Marks unused exports for removal. Relies on Terser to detect side effects in statements. Cannot skip entire subtrees of dependencies.

sideEffects (Module-Level)

Allows skipping whole modules and complete subtrees. More effective for large-scale elimination. Requires accurate declaration of side effects.

Combined Effect

Used together, these optimizations provide comprehensive dead code elimination across your entire dependency tree.

TypeScript Configuration for Tree Shaking

TypeScript adds another layer of consideration for tree shaking. The TypeScript compiler must be configured to output ES modules, and build tools must be configured to preserve the ES module structure for tree shaking analysis. Understanding how TypeScript handles module output is critical--learn more in our guide on Babel vs TypeScript: Choosing the Right Compiler.

tsconfig.json Settings

Key TypeScript configuration options for optimal tree shaking:

{
 "compilerOptions": {
 "target": "ES2022",
 "module": "ESNext",
 "moduleResolution": "bundler",
 "strict": true,
 "esModuleInterop": true,
 "skipLibCheck": true,
 "forceConsistentCasingInFileNames": true,
 "declaration": true
 }
}

Critical options:

  • "module": "ESNext" ensures TypeScript outputs ES modules that can be tree shaken
  • "moduleResolution": "bundler" helps with proper module resolution matching bundler behavior
  • Avoid setting module to "CommonJS" or webpack won't be able to tree shake your code

Make sure to avoid setting module to "CommonJS"

Avoiding Common TypeScript Pitfalls

Several TypeScript-specific issues can prevent effective tree shaking:

1. Babel transforms: If using Babel with TypeScript, ensure it's not converting ES modules to CommonJS. Use @babel/preset-typescript with { modules: false }.

2. Re-exporting entire namespaces: Instead of export * from './math';, prefer explicit exports:

// Better for tree shaking
export { add, multiply } from './math';

3. const enums: Use const enums for complete elimination--they're inlined and removed during compilation.

4. Type-only imports: Use import type { Type } to import types without affecting tree shaking.

Code Splitting: Delivering Code on Demand

Code splitting is a technique that allows you to split your code into smaller chunks, which can be loaded on demand. While tree shaking removes unused code, code splitting manages how and when code is loaded. Together, they form a powerful optimization strategy that reduces initial bundle size and improves perceived performance.

Code splitting is essential for large applications where loading everything upfront would create unacceptable initial load times. By splitting your code into smaller chunks and loading them only when needed, you can significantly improve Time to Interactive and user experience. For a comprehensive approach to managing build tooling across multiple packages, see our guide on managing full-stack monorepos with pnpm.

Types of Code Splitting

Entry Points

Multiple entry points can be defined in your bundler configuration. Each entry point produces separate bundles. This is useful for multi-page applications where different pages require different code.

module.exports = {
 entry: {
 main: './src/index.ts',
 admin: './src/admin.ts',
 },
 output: {
 filename: '[name].bundle.js',
 },
};

Dynamic Imports

Dynamic imports use the import() syntax to load modules on-the-fly:

// Dynamically import a module only when needed
async function loadHeavyModule() {
 const { HeavyComponent } = await import('./heavy-component');
 return HeavyComponent;
}

Dynamic imports return a Promise, making them perfect for lazy loading components, modal dialogs, or any code that isn't needed immediately on page load.

Route-Based Splitting

Route-based splitting associates different routes with different bundles:

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./routes/Dashboard'));
const Settings = lazy(() => import('./routes/Settings'));

function App() {
 return (
 <Suspense fallback={<div>Loading...</div>}>
 <Routes>
 <Route path="/dashboard" element={<Dashboard />} />
 <Route path="/settings" element={<Settings />} />
 </Routes>
 </Suspense>
 );
}

The SplitChunksPlugin

Webpack's SplitChunksPlugin optimizes the chunks created by code splitting. It can automatically extract common dependencies into separate chunks that can be cached independently.

module.exports = {
 optimization: {
 splitChunks: {
 chunks: 'all',
 cacheGroups: {
 vendor: {
 test: /[\\/]node_modules[\\/]/,
 name: 'vendors',
 priority: 10,
 },
 },
 },
 },
};

This configuration extracts all code from node_modules into a separate vendor bundle. The vendor bundle can be cached independently from your application code, reducing the amount of data users need to download when your application changes. For a deeper dive into optimizing package management and reducing bundle sizes, see our guide on slimming down your bundle size.

Practical Example: Tree Shaking in Action

Let me demonstrate tree shaking with a practical TypeScript example that shows exactly how unused code is eliminated.

Creating a Utility Library

// src/math.ts
export function add(a: number, b: number): number {
 console.log('add function called');
 return a + b;
}

export function subtract(a: number, b: number): number {
 console.log('subtract function called');
 return a - b;
}

export function multiply(a: number, b: number): number {
 console.log('multiply function called');
 return a * b;
}

export function divide(a: number, b: number): number {
 console.log('divide function called');
 return a / b;
}

Using Only Some Functions

// src/index.ts
import { add, multiply } from './math';

console.log(add(2, 3));
console.log(multiply(2, 3));

After building with tree shaking enabled, only the add and multiply functions will be included in the bundle. The subtract and divide functions will be completely eliminated from the output.

Package.json Configuration

{
 "name": "tree-shaking-demo",
 "version": "1.0.0",
 "main": "dist/index.js",
 "module": "dist/index.js",
 "type": "module",
 "sideEffects": false,
 "scripts": {
 "build": "tsc && webpack --mode=production"
 }
}

Setting "sideEffects": false tells the bundler that this package has no side effects, allowing unused exports and their entire dependency trees to be safely eliminated. Understanding package manager nuances is important--see our guide on advanced npm, yarn, and pnpm features for more details.

Best Practices for Maximum Efficiency

Use ES Modules Exclusively

Always use ES module syntax (import/export) instead of CommonJS. Mixing module systems can prevent effective tree shaking.

// Good - ES modules
import { debounce } from 'lodash-es';

// Avoid - CommonJS
const _ = require('lodash');

Be Explicit with Third-Party Libraries

Many modern libraries support tree shaking, but you need to import them correctly:

// Bad - imports entire library
import _ from 'lodash';

// Good - imports only what you need
import { debounce } from 'lodash-es';

// Even better - imports directly from the module
import debounce from 'lodash-es/debounce';

Mark Packages as Side-Effect-Free When Possible

When creating libraries, mark them as side-effect-free to enable maximum tree shaking:

{
 "sideEffects": false
}

Avoid Top-Level Side Effects

Top-level code with side effects prevents tree shaking:

// This prevents tree shaking of this module
window.myGlobal = initializeSomething();

// Better - wrap in a function
function setup() {
 window.myGlobal = initializeSomething();
}

Measuring Tree Shaking Effectiveness

Use the Webpack Bundle Analyzer to visualize bundle composition and verify tree shaking:

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
 plugins: [
 new BundleAnalyzerPlugin(),
 ],
};

Run the build and examine the generated report to:

  • Identify unused code that wasn't eliminated
  • Verify that only used exports are included
  • Compare bundle sizes before and after optimizations
  • Find opportunities for further optimization

The bundle analyzer provides a visual representation of your bundle, making it easy to spot large dependencies that could be optimized or replaced with smaller alternatives. For development tooling recommendations, see our guide on modern ESLint alternatives.

Common Pitfalls and How to Avoid Them

1. Accidental Side Effects

Avoid code that has side effects at the top level of modules:

// Problematic - this has side effects at import time
const config = fetchConfigFromServer();

// Better - wrap in a function
async function getConfig() {
 return fetchConfigFromServer();
}

2. CommonJS Modules

Avoid CommonJS modules when possible, as they can't be effectively tree shaken:

// Problematic - CommonJS
module.exports = { add, multiply };

// Good - ES modules
export const add = (a: number, b: number): number => a + b;
export const multiply = (a: number, b: number): number => a * b;

3. Re-exporting Entire Namespaces

Instead of re-exporting everything:

// Can prevent tree shaking
export * from './math';

Prefer explicit exports:

// Better for tree shaking
export { add, multiply } from './math';

4. Testing Only in Development Mode

Tree shaking optimizations are typically disabled in development mode. Always verify optimizations in production builds.

5. Babel Transforms

If using Babel with TypeScript, ensure it preserves ES modules:

// babel.config.js
module.exports = {
 presets: [
 ['@babel/preset-typescript', { modules: false }],
 ],
};

Combining Tree Shaking and Code Splitting

Tree shaking and code splitting are most powerful when used together. Tree shaking removes unused code within chunks, while code splitting manages the chunks themselves.

// src/components.ts
export function Button() { /* ... */ }
export function Modal() { /* ... */ }
export function Form() { /* ... */ }

// src/App.tsx
import { lazy, Suspense } from 'react';

// Only load Modal when needed - lazy loaded and tree shaken
const Modal = lazy(() => 
 import('./components').then(m => ({ default: m.Modal }))
);

function App() {
 return (
 <Suspense fallback={<div>Loading...</div>}>
 <Button />
 {/* Modal chunk only loads when isOpen becomes true */}
 {isOpen && <Modal />}
 </Suspense>
 );
}

In this example:

  • Tree shaking ensures only Modal is included from the components file
  • Code splitting creates a separate chunk for Modal
  • The Modal chunk is only loaded when needed
  • Initial bundle size is significantly reduced

Frequently Asked Questions

Does tree shaking work with CommonJS modules?

No, tree shaking relies on the static structure of ES modules. CommonJS modules (require/module.exports) use dynamic imports that cannot be analyzed at build time. Always use ES modules for tree shakeable code.

How do I know if tree shaking is working?

Use the Webpack Bundle Analyzer to visualize your bundle and see which code is included. Compare bundle sizes between development and production builds. Production builds with tree shaking should be significantly smaller.

Will tree shaking break my code?

Tree shaking only removes code that is provably unused. It won't remove code that has side effects or is imported anywhere. However, always test thoroughly in production mode to verify your bundle works correctly.

Should I use sideEffects: false in my package.json?

Only if your package has no side effects (code that executes at import time). If your package includes CSS files or polyfills that must execute, specify those files explicitly in the sideEffects array instead.

Sources

  1. Webpack.js.org - Tree Shaking Guide - Official webpack documentation covering fundamentals, sideEffects configuration, and common pitfalls
  2. Webpack.js.org - Code Splitting Guide - Entry points, dynamic imports, SplitChunksPlugin documentation
  3. Billy Okeyo - Tree Shaking in TypeScript - TypeScript-specific configuration, tsconfig.json settings, and advanced techniques
  4. NamasteDev - Tree-Shaking and Code-Splitting: Real-World Bundle Size Reductions - Practical guide with React examples, dynamic imports, and best practices

Optimize Your Web Application Performance

Our team of TypeScript experts can help you implement advanced build optimizations, reduce bundle sizes, and deliver exceptional user experiences.