Tree shaking, also known as dead code elimination, is the practice of removing unused code in your production build. It’s important to ship as little code to your end-users as possible. By statically analyzing our source code, we can determine what’s not being used and exclude it from our final bundle.
Code splitting, on the other hand, refers to splitting your production build code into multiple modules that are loaded on demand. If you’re utilizing a third-party library in your code after some user interaction, we can exclude that third-party code in our initial bundle and only load it when needed to achieve faster load times.
In webpack, tree shaking works with both ECMAScript modules (ESM) and CommonJS, but it does not work with Asynchronous Module Definition (AMD) or Universal Module Definition (UMD).
ESM allows for the most optimal tree shaking because CommonJS, AMD, and UMD can all be non-deterministic and thus, impossible to statically analyze for effective dead code elimination.
In Node.js, for example, you can conditionally run require
with a variable to load a random script. Webpack can’t possibly know all of your imports and exports at build time, so it will attempt to tree shake a handful of constructs and bail as soon as things get too dynamic.
This is true for ESM as well, the following code can force webpack to opt out of tree shaking app.js
because the use of the import is not static.
import * as App from 'app.js' const variable = // some variable console.log(App[variable])
And, although UMD is an appealing choice as a module system because it works everywhere, it can’t be tree shaken, so, according to Sean Larkin at Microsoft, it’s best to stick to ESM and let the developers consuming your code handle the conversion from one module system to the other.
When working with webpack, you will realize that some code is more tree-shakable than other similarly functioning code. It’s impossible to cover all the heuristics webpack employs in order to tree shake your code, so we will limit the use cases to a few important ones.
To get a basic webpack project running, install webpack
and webpack-cli
.
$ yarn init -y $ yarn add -D webpack webpack-cli
Create two files inside a src
directory, src/index.js
and src/person.js
:
// src/person.js export const person = { name: "John", age: 30 };
In person.js
, export a person
object to be used in other modules.
// src/index.js import { person } from "./person"; console.log(person.name);
Running yarn webpack
will, by default, use src/index.js
as the entry point and output a dist/main.js
build file. The command will also warn us that we are not setting a mode
and will run webpack in production
mode.
If you open build/main.js
, you will find the following unformatted code, which is a far cry from the source code we wrote.
// dist/main.js (() => { "use strict"; console.log("John"); })();
Notice that webpack wrapped code in IIFE and bundled all the modules into a single file, and it will continue to do so until we tell it otherwise.
It also correctly determined that we did not use the person
object in its entirety, nor did we need a person
variable to begin with.
If we reuse person.name
(by duplicating our console.log
call, for instance,) webpack will maintain it in our bundle after it’s been optimized and minimized but will continue to tree shake the unused properties from our person
object:
// dist/main.js (() => { "use strict"; const o = "John"; console.log(o), console.log(o); })();
Using this setup, let’s explore some importing and exporting patterns that we use in our modules.
We will switch to a component.js
file to work with familiar subjects. In component.js
, we can write code that you would find in an open-source component library and export a handful of components:
// src/component.js export const Root = () => "root"; export const Title = () => "title"; export const Overlay = () => "overlay";
In index.js
, we use the Title
component:
// src/index.js import { Title } from "./component"; console.log(Title());
Compiling these two files, we get the following code:
// dist/main.js
(() => {
"use strict";
console.log("title");
})();
Using namespace imports works identically to having named imports in terms of tree shakability.
We can find this pattern suggested in several public packages’ documentation such as Yup’s and Radix UI’s. In webpack 5, this has been enhanced to cover nested imports as well.
// src/index.js import * as Component from "./component"; console.log(Component.Title());
Bundling this code would result in the exact same output as before.
Namespace imports allow us to encapsulate several imports under one object. Some library authors take this matter into their own hands, though, and create that object for you, then usually export it as a default export a la React.
// src/component.js export const Root = () => "root"; export const Title = () => "title"; export const Description = () => "description"; Root.Title = Title; Root.Description = Description;
It’s common to see this pattern, where one component is assigned the rest of the components. You can find this pattern used in HeadlessUI through an Object.assign
call, for example.
Unfortunately, it is no longer tree-shakable because the Root.
assignments are dynamic and can be called conditionally. Webpack cannot statically analyze this anymore, and the bundle will look like this:
// dist/main.js (() => { "use strict"; const t = () => "root"; (t.Title = () => "title"), (t.Description = () => "description"), console.log("title"); })();
Although we aren’t using the description
function anywhere, it’s shipped in production code.
We can fix this and maintain a similar experience by exporting an actual object:
// src/component.js export const Root = () => "root"; export const Title = () => "title"; export const Description = () => "description"; export const Component = { Root, Title, Description, };
// src/index.js import { Component } from "./component"; console.log(Component.Title());.
// dist/main.js (() => { "use strict"; console.log("title"); })();
Unlike functions, classes are not statically analyzable by bundlers. If you have a class like the following, the methods greet
and farewell
cannot be tree shaken even if they are not used.
// src/person.js export class Person { constructor(name) { this.name = name; } greet(greeting = "Hello") { return `${greeting}! I'm ${this.name}`; } farewell() { return `Goodbye!`; } }
// src/index.js import { Person } from "./person"; const John = new Person("John"); console.log(John.farewell());
Although we’re only using the farewell
method and not the greet
method, our bundled code contains both farewell
and greet
methods.
To get around this, we can extract the methods as stand-alone functions that take the class as an argument.
// src/person.js export class Person { constructor(name) { this.name = name; } } export function greet(person, greeting = "Hello") { return `${greeting}! I'm ${person.name}`; } export function farewell() { return `Goodbye!`; }
Now, we import greet
, which results in farewell
being tree shaken from our bundle.
// src/index.js import { Person, greet } from "./person"; const John = new Person("John"); console.log(greet(John, "Hi")); // "Hi! I'm John"
In functional programming, we are accustomed to working with pure code. We import and export code that simply receives input and yields output. By contrast, code that has side effects is code that modifies something in a global context (e.g., polyfills).
Modules that are side effects cannot be tree shaken because they don’t have imports and exports.
But, code doesn’t have to be a module to have side effects. Take the following code as an example:
// src/side-effect.js export const foo = "foo"; const mayHaveSideEffect = (greeting) => { fetch("/api"); return `${greeting}!!`; }; export const bar = mayHaveSideEffect("Hello");
// src/index.js import { foo } from "./side-effect"; console.log(foo);
The bar
variable triggers a side effect as it’s initialized. Webpack realizes this and has to include the side effect code in the bundle, even though we aren’t using bar
at all:
// dist/main.js (() => { "use strict"; fetch("/api"), console.log("foo"); })();
To instruct webpack to drop the side effect of initializing bar
, we can use the PURE
magic comment, like so:
// src/side-effect.js export const bar = /*#__PURE__*/ mayHaveSideEffect("Hello"); // dist/main.js (() => { "use strict"; console.log("foo"); })();
Before webpack, developers used a combination of script tags, IIFE, and JSON with padding (JSONP) to organize and write modular code.
Take this example:
<body> <script src="global.js"></script> <script src="carousel.js"></script> <!-- carousel.js depends on global.js --> <script src="shop.js"></script> <!-- shop.js depends on global.js --> </body>
If carousel.js
were to declare a variable with a name that’s already declared in global.js
, it would overwrite it and crash the entire app. So, IIFEs were used to encapsulate code from affecting other code.
var foo = 'bar'; (function () { var foo = 'baz'; })()
An IIFE is a function that calls itself immediately, creating a new scope in the process that doesn’t interfere with the previous scope.
The last piece in this workflow is the use of JSONP, which was created back when CORS wasn’t standardized yet, and requesting a JSON file from a server was prohibited in the browser.
JSONP is a JavaScript file that calls a predefined function with certain data or logic immediately when requested. Note that the function does not have to be JSON.
<script type="text/javascript"> var callback = function(json) { console.log(json) } </script> <script type="text/javascript" src="https://example.com/jsonp.js"></script> <!-- // jsonp.js contains: callback("The quick brown fox jumps over the lazy dog") when https://example.com/jsonp.js gets loaded, "The quick brown fox..." will be logged to the console immediately. -->
You can see that using these concepts to modularize our code can be cumbersome and error-prone. But in reality, these are the very same concepts that power webpack. All that webpack does is automate this process through static analysis while providing a top-notch developer experience and extra features, among which is tree shaking.
It is apparent that code splitting or lazy loading is just webpack creating and appending more script tags that are referred to in the webpack world as chunks.
The code that handles lazy-loaded modules is already on the page. And, JSONP is used to execute that code as soon as the module is loaded.
<script type="text/javascript"> var handleLazyLoadedComponent = function(component) {/* ... */} </script> <script type="text/javascript" src="chunk.js"></script> <!-- chunk.js calls handleLazyLoadedComponent with the right code to work seamlessly -->
To utilize code splitting, we can use the global import
function:
// src/lazy.js export const logger = console.log;
// src/index.js const importLogger = () => import("./lazy"); document.addEventListener("click", () => { importLogger().then((module) => { module.logger("hello world"); }); });
In index.js
, instead of importing our logger
function statically, we chose to import it on demand when an event gets fired. import
returns a promise that resolves with the whole module.
In our bundled code, we now see two files instead of one, effectively splitting our code.
Because webpack bundles our app at build-time using static analysis, it cannot provide truly dynamic importing at run-time. If you attempt to use the import
function with a variable (i.e., import(someVariable)
), webpack will warn you not to. But, if you give webpack a hint on where to look for your dynamic modules, it would code split them all at build-time in anticipation of using them.
As an example, let’s say we have a numbers
directory with three files: one.js
, two.js
, and three.js
, which exports numbers:
// src/numbers/one.js export const one = 1; // src/numbers/two.js export const two = 2; // src/numbers/three.js export const three = 3;
If we want to dynamically import these files, we need to hardcode the path in the import
function call:
// src/index.js const getNumber = (number) => import(`./numbers/${number}.js`); document.addEventListener("click", () => { getNumber("one").then((module) => { console.log(module.one); }); });
If we have modules that are not .js
files (e.g., JSON or CSS files) inside our numbers
directory, it helps to narrow down the imports to JavaScript files by including that in the import call.
This will create three additional bundles even though we’re only using one bundle in our code.
Dynamic imports resolve the entire module — with its default and named exports — without tree shaking unused imports.
To dynamically import a node module and tree shake it, we can first create a module that only exports what we want, then dynamically import it.
Some libraries like Material-UI and lodash.es are structured in a way that you can access exports based on the file structure. In that case, we can skip re-exporting the module and just import it directly in the first place.
In this article, we covered tree shaking in webpack and learned how to make common patterns tree-shakable. We also covered how webpack works under the hood when it comes to code splitting, as well as how to dynamically import modules at runtime. Lastly, this article covered how to combine tree shaking and code splitting for the most optimal bundle possible. Thanks for reading.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowValidating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.
Learn how Remix enhances SSR performance, simplifies data fetching, and improves SEO compared to client-heavy React apps.