Web Performance Optimisation Techniques III - Tree Shaking

Tree shaking is a term borrowed from the concept of shaking a tree to remove dead leaves. In the context of web development, it refers to the process of eliminating unused or "dead" code from an application during the build process. This is particularly useful in JavaScript applications, where large libraries might be imported, but only a small portion of them is actually used.

The term was popularised by the JavaScript bundler Rollup, but it has since been adopted by other bundlers like Webpack, Parcel, and Vite. In this guide, we’ll explore the importance of tree shaking, how it works under the hood, and best practices to maximise its effectiveness.

Why is Tree Shaking Important?

  • Smaller Bundle Sizes:

    One of the primary benefits of tree shaking is that it reduces the size of the final JavaScript bundle. Smaller bundles mean faster load times, which is crucial for user experience, especially on mobile devices or slow networks.

  • Improved Performance:

    By removing unused code, applications become more efficient. Less code means less parsing, compiling, and executing, which leads to better runtime performance.

  • Dead Code Identification:

    Highlights unused modules or dependencies during bundling, helping developers identify and remove dead code from source files. This reduces bloat and technical debt, ensuring a leaner, more maintainable codebase over time

The Mechanics of Tree shaking

To understand how tree shaking works, it is essential to dive into the mechanics behind it. At its core, tree shaking relies on static analysis, a process where the bundler analyses code without executing it.

Let’s break this down.

The Role of Static Analysis

Static analysis is the backbone of tree shaking. It allows the bundler to analyse code at build time and determine which parts are actually used. This is only possible with static code.

Static vs. Dynamic Code

  • Static Code: Code that can be analysed at build time or before the application runs. (e.g., import and export statements in ES6 modules).

  • Dynamic Code: Code that cannot be analysed until runtime (e.g.,require statements in CommonJS or dynamic imports).

Tree shaking works best with static code because the bundler can make decisions about what to include or exclude before the application runs.

How Tree Shaking Works

Tree shaking is a multi-step process that occurs during the build phase of an application. To better understand how this process works, let’s walk through a simple example step by step.

Consider the following two files:

  1. math.js
// math.js
export const add = (a, b) => a + b;

export const subtract = (a, b) => a - b;

export const multiply = (a, b) => a * b;

export const divide = (a, b) => a / b;
  1. main.js
// main.js
import { add, multiply } from './math.js';

console.log(add(5, 3));       // Only `add` is used
console.log(multiply(2, 4));  // Only `multiply` is used

In this example,

  • The math.js module exports four functions: add, subtract, multiply, and divide.

  • However, in main.js, only the add and multiply functions are imported and used.

  • The goal of tree shaking is to ensure that only these two functions are included in the final bundle, while subtract and divide are removed as unused code.

Step-by-Step Process of Tree Shaking

  1. Static Analysis of Imports and Exports

    The bundler analyses all import statements in the code to identify which modules and functions are being used. It also examines the export statements to understand what is being exposed by each module.

    How Static Analysis Works

    Static analysis is typically performed using an Abstract Syntax Tree (AST). An AST is a tree representation of the structure of the code, where each node corresponds to a construct in the source code (e.g., a function, variable, or expression).

    For example, the AST for math.js might look like this:

     {
       "type": "Program",
       "body": [
         {
           "type": "ExportNamedDeclaration",
           "declaration": {
             "type": "FunctionDeclaration",
             "id": { "name": "add" },
             "params": [...],
             "body": [...]
           }
         },
         {
           "type": "ExportNamedDeclaration",
           "declaration": {
             "type": "FunctionDeclaration",
             "id": { "name": "subtract" },
             "params": [...],
             "body": [...]
           }
         },
         ...
       ]
     }
    

    The bundler uses the AST to:

    • Identify which functions are exported from math.js (add, subtract, multiply, and divide).

    • Determine which functions are imported and used in main.js (add and multiply).

By analysing these statements, the bundler can determine which functions are used and which are not.

  1. Building a Dependency Graph

    Once the bundler has analysed the imports and exports, it constructs a dependency graph. This graph represents the relationships between modules and functions in the application.

    • Nodes: Each module or function is a node in the graph.

    • Edges: The edges represent dependencies between modules (e.g., main.js depends on math.js).

The dependency graph helps the bundler understand which parts of the code are reachable from the entry point of the application (e.g., main.js).

For example, the dependency graph for the above code might look like this:

    main.js
      └── math.js
          ├── add
          └── multiply

In this graph:

  • main.js is the entry point.

  • math.js is a dependency of main.js.

  • Only add and multiply are connected to the entry point (main.js), meaning they are used in the application.

The subtract and divide functions are not connected to the entry point, so they are considered unreachable.

  1. Dead Code Elimination

    After building the dependency graph, the bundler identifies and removes any code that is not reachable from the entry point. This process is known as dead code elimination.

    • Reachable Code: Code that is directly or indirectly referenced from the entry point is kept.

    • Unreachable Code: Code that is not referenced (e.g., unused functions or modules) is removed.

In the example above, the subtract and divide functions are unreachable and will be eliminated.

Challenges with Tree Shaking

While tree shaking is a powerful optimisation technique, it is not without its challenges:

  1. Dynamic Code

    Because tree shaking relies on static analysis, it cannot determine which parts of dynamically loaded modules will be used. As a result, the bundler may include the entire module in the final bundle, even if only a small portion is needed.

    Example: Dynamic Imports

     import('./math.js').then(math => {
       console.log(math.add(2, 3));
     });
    

    In this case:

    • The bundler cannot determine which functions from math.js will be used at runtime.

    • As a result, it may include the entire math.js module in the bundle, even if only the add function is needed.

Another Example: CommonJS Modules

CommonJS is a module system used in Node.js and older JavaScript projects. It uses require and module.exports to define and import modules. However, CommonJS is inherently dynamic, which makes tree shaking difficult or impossible.

Let’s rewrite our earlier example to use commonJS:

math.js

    // math.js
    exports.add = (a, b) => a + b;
    exports.subtract = (a, b) => a - b;
    exports.multiply = (a, b) => a * b;
    exports.divide = (a, b) => a / b;

main.js

    // main.js
    const math = require('./math.js');

    console.log(math.add(2, 3));       // Only `add` is used
    console.log(math.multiply(2, 4));  // Only `multiply` is used

In this example:

  • The require statement dynamically loads the math.js module.

  • The bundler cannot determine which functions (add, subtract, multiply, divide) will be used at runtime.

  • As a result, the entire math.js module is included in the bundle, even though only add and multiply are used.

  1. Side Effects

    Code with side effects cannot be safely removed, even if it appears unused.

    For example:

     // side-effect.js
     console.log('This is a side effect!');
    
     // main.js
     import './side-effect.js';
    

    Even though side-effect.js does not export anything, the console.log statement is a side effect, so it will be included in the bundle.

Best Practices for Effective Tree Shaking

To get the most out of tree shaking, follow these best practices:

  1. Use ES6 Modules

    • Whenever possible, use ES6 modules (import/export) instead of CommonJS (require/module.exports). ES6 modules have a static structure that enables effective tree shaking.
  2. Avoid Dynamic Imports

    • Use static imports whenever possible. Reserve dynamic imports for cases where lazy loading is absolutely necessary.
  3. Minimise Side Effects

    • Whenever possible, try to write pure functions as they are easier to tree shake because they don’t have side effects.

    • However, If your code has side effects, use the sideEffects property in package.json to indicate which files have side effects. This helps the bundler make better decisions about what to include or exclude.

      Example:

        //package.json
        {
          "sideEffects": [
            "src/some-side-effectful-file.js",
            "*.css"
          ]
        }
      
  1. Optimise Third-Party Libraries

    • Look for libraries that support ES6 modules and have minimal side effects.

    • Also, use tools like Babel or Rollup to convert a third-party library that uses CommonJS to ES6 modules.

Below is a table summarising the popular bundlers that support tree shaking:

BundlerKey Features
WebpackRequires mode: 'production' and sideEffects property in package.json.
RollupFirst bundler to introduce tree shaking. Automatically performs tree shaking with ES6 modules.
ParcelZero-configuration bundler with built-in tree shaking support.
ViteUses ESBuild for fast bundling and Rollup for production builds. Supports tree shaking during development and production.

In Conclusion,

Tree shaking is a powerful technique for optimizing JavaScript applications. By eliminating unused code, it significantly reduces bundle sizes, improves performance, and enhances maintainability. While tree shaking is most effective with ES6 modules, it’s important to follow best practices and be aware of common pitfalls to get the most out of it.

0
Subscribe to my newsletter

Read articles from MyCodingNotebook directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

MyCodingNotebook
MyCodingNotebook

MyCodingNotebook is every developer’s online notebook—a space to explore coding concepts, share insights, and learn best practices. Whether you're just starting or an experienced developer, this blog serves as a go-to resource for coding best practices, real-world solutions, and continuous learning. Covering topics from foundational programming principles to advanced software engineering techniques, MyCodingNotebook helps you navigate your coding journey with clarity and confidence. Stay inspired, keep learning, and continue growing in your coding journey.