You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tree shaking has become an extremely important and indispensable part of modern front-end bundling. Given the differences in applicable scenarios and focus areas among various bundlers, their implementations of tree shaking also vary. For example, webpack is primarily used for front-end application bundling and emphasizes correctness, with its tree shaking focusing on cross-module-level optimizations. On the other hand, Rollup is mainly used for library bundling and prioritizes optimization efficiency, performing tree shaking at the granularity of AST nodes, which typically results in smaller bundle sizes. However, it may lack guarantees for the execution correctness of certain edgecases.
This article will provide a brief overview of the tree shaking principles of various bundlers and the differences between them.
What is tree shaking: You can imagine your application as a tree. The source code and libraries you actually use represent the green, living leaves of the tree. Dead code represents the brown, dead leaves of the tree that are consumed by autumn. In order to get rid of the dead leaves, you have to shake the tree, causing them to fall.
Tree shaking in webpack / Rspack
Currently, Rspack (v1.4) follows the same tree shaking implementation as webpack, so the following introduction will use webpack's implementation approach as an example. At the same time, we are actively exploring more efficient tree shaking strategies for future versions of Rspack.
webpack's tree shaking consists of three parts:
module-level:optimization.sideEffects Remove modules that have no used exports and no side effects.
import "./module"; No using any exports and have no side effects, so ./module can be safely removed.
re-exports.js (barrel file), which itself doesn't have any local exports is being used, and have no side effects, so re-exports.js can be safely removed
// index.jsimport{a}from"./re-exports";console.log(a);// re-exports.jsexport*from"./module";// `index -(a)-> re-exports -(a)-> module` optimized to `index -(a)-> module`, re-exports.js itself doesn't have any local exports is being used, and it's side effects free, so re-exports.js can be safely removed// module.jsexportconsta=42;
export-level:optimization.providedExports and optimization.usedExports, to remove unused exports
optimization.providedExports to analyze what exports a module has
optimization.usedExports to analyze which exports of a module are actually used, unused exports can be removed during code generation: export const a = 42 => const a = 42, and then a minimizer (such as SWC or Terser) can further eliminate the remaining declaration if the variable is also unused within the module itself
code-level:optimization.minimize Use minifiers like SWC or Terser to analyze code through techniques such as inlining and evaluation, removing dead code and performing compression to minimize bundle size as much as possible.
Minifiers are integrated with bundlers through the optimization.minimizer plugin, performing post-processing on the bundler's output, which falls outside the bundler's core responsibilities
In addition, another important part is static analysis. Both module-level and export-level optimizations require webpack to perform static analysis on the code to determine whether a module has side effects, what exports it contains, and which exports are actually used. This information serves as input for these optimization phases.
Theoretically, as long as webpack's JavaScript parser can statically extract this information, tree shaking can be applied—even to CommonJS and dynamic imports, which are inherently dynamic. If these constructs follow a static pattern and the necessary information can be statically inferred, tree shaking remains possible.
However, currently, webpack only performs static analysis for very limited scenarios involving CJS and dynamic imports. Many analyzable cases remain unoptimized, leaving significant room for improvement.
After these three steps are completed, tree shaking is already functional, but there can still be issues with certain cases:
The export a is referenced by another unused export g in a different module, preventing a from being tree-shaken. In this case, optimization.innerGraph is needed to analyze the dependency relationships between top-level statements in lib.js. Only when the top-level statement containing a is used will a be marked as used; otherwise, it will be considered unused.
// index.jsimport{f}from"./lib";f();// lib.jsimport{a}from"./module";exportconstf=()=>42;exportconstg=()=>a;// Since `g` is unused, generating `const g = () => a` causes `a` to be referenced, preventing `a` from being tree-shaken.// module.jsexportconsta=42;
Some optimizable cases by minifiers cannot be optimized due to webpack's runtime wrapping each module in a function. For example, in the following case:
// Enabling `optimization.concatenateModules` makes the output more minifier-friendly, allowing for better compression and smaller bundle sizes.;// CONCATENATED MODULE: ./constants.jsconstA=42;// CONCATENATED MODULE: ./index.jsconsole.log(A ? 1 : 2)// now this can be optimized by minifier
The minifier can effectively shorten variable names through mangling, but this wrapper function prevents it from mangling exported module variables. This is where optimization.mangleExports comes into play to handle the mangling.
Splitting top-level statements in advance allows esbuild to inherently solve the innerGraph issue. After splitting, each top-level statement becomes a part, enabling analysis and optimization at the part-level granularity, unlike webpack which operates at the module-level granularity. This approach offers the following key advantages:
It analyzes not only imported/exported variables but also variables within modules, covering the work that webpack requires innerGraph to accomplish.
It applies bundler optimizations at the module-level to top-level statements inside modules (part-level). For example, esbuild's side effects optimization can remove unused and side effect free top-level statements, while webpack can only perform module-level removal.
It's worth noting that early versions of esbuild also allowed these top-level statements to participate in code splitting, which is what we now call "module splitting". However, since esbuild does not wrap each module in a function like webpack's output—which separates module loading and execution—it struggled to properly handle top-level await. As a result, esbuild later dropped support for module splitting.
Currently, esbuild/docs/architecture/code-splitting still corresponds to the version that supports module splitting. Code splitting is performed at the part-level, with the shared chunk containing only the top-level statements common to both entry points. A replica in the esbuild playground shows that the shared chunk includes modules shared by both entries, with code splitting being done at the module level.
The result that before support module splitting
The result that now no longer support module splitting
Module splitting is actually more suitable for bundlers like webpack, which can separate module loading and execution, inherently ensuring the correctness of module execution without requiring additional handling of variable declarations and usage within modules.
Additionally, it allows applying more module-level optimizations to top-level statements inside modules, such as optimizations that esbuild lacks or underperforms in: code splitting, runtime optimization, chunk splitting, etc., enabling further optimizations.
So, is there a bundler that combines webpack-like runtime (separating module loading and execution) with support for module splitting? Yes: Turbopack.
First, let's examine how Turbopack's output format solves the issue of disallowing variable assignments and declarations across chunks, using the example from esbuild's bundling result:
// PS: The relevant code in Turbopack has been simplified and modified. Currently, Turbopack does not yet provide configurations to fine-grained control over chunk splitting result.// chunk for entry1.jsmodule.exports={"entry1.js": (__turbopack_context__)=>{var_data_1__TURBOPACK_MODULE__=__turbopack_context__.i("data.js <part 1>");console.log(_data_1__TURBOPACK_MODULE__.data);},}// chunk for entry2.jsmodule.exports={"entry2.js": (__turbopack_context__)=>{var_data_2__TURBOPACK_MODULE__=__turbopack_context__.i("data.js <part 2>");_data_2__TURBOPACK_MODULE__.setData(123);},"data.js <part 2>": (__turbopack_context__)=>{__turbopack_context__.s({setData: ()=>setData,});var_data_1__TURBOPACK_MODULE__=__turbopack_context__.i("data.js <part 1>");functionsetData(value){_data_1__TURBOPACK_MODULE__.data=value;// <--- this will tigger the setter}},}// chunk for shared codemodule.exports={"data.js <part 1>": (__turbopack_context__)=>{__turbopack_context__.s({data: [()=>data,// <--- getter(new_data)=>data=new_data,// <--- setter]});letdata;},}
Turbopack uses an output format similar to webpack, wrapping each module in a function to separate module loading and execution. But unlike webpack, the runtime __turbopack_context__.s that defines module exports not only provides a getter for exports but also includes an additional setter. When other parts of the module perform assignment operations on these variables, the corresponding setters are triggered to update the values, ensuring correct execution.
For top-level await, like webpack, Turbopack employs a runtime to guarantee the correct execution order of modules containing top-level await and their imported dependencies. For example, after adding await 1; to the first line of data.js, the bundled output is as follows:
// chunk for shared codemodule.exports={// ..."data.js <part 0>": (__turbopack_context__)=>{__turbopack_context__.a(async(__turbopack_handle_async_dependencies__,__turbopack_async_result__)=>{try{await1;// <--- the added `await 1;`, wrapped by __turbopack_context__.a runtime to ensure top-level await is correctly executed__turbopack_async_result__();}catch(e){__turbopack_async_result__(e)}},true);},// ...}
Of course, module splitting also has its drawbacks. After splitting, each top-level statement in the output is wrapped in a function. While this ensures correct execution, it amplifies the downsides of this wrapping approach:
Excessive duplication of these wrapper functions increases bundle size (though gzip can effectively reduce this overhead).
More variables must be accessed via object properties like _data_0__TURBOPACK_MODULE__.data, potentially degrading runtime performance (this still requires modern browser benchmarks for validation).
Both issues necessitate greater reliance on scope hoisting for optimization.
Tree shaking in Rollup
If webpack performs tree shaking on export statements and modules, and esbuild does it on top-level statements, then Rollup conducts tree shaking from top to bottom for all statements and even finer-grained AST nodes, with more precise side effects detection.
Rollup performs tree shaking on statements and some finer-grained AST nodes.
Rollup's tree shaking is similar to esbuild's but with slight differences. The process works as follows:
Start by calling include() on the module.
Begin from the top-level AST node:
a. Determine if the AST node has side effects.
b. If yes, call include().
c. Continue side effect checks and include() on related AST nodes (repeating steps a and b).
After traversal, check if new AST nodes have been include()-ed. If so, trigger a new traversal (steps 1 and 2).
In step 2.c, "related nodes" refer to: child nodes of certain nodes, variable declaration nodes corresponding to variable usage, and nodes for properties like obj.a.b when accessing obj's declarations and properties a and b.
Rollup often achieves better tree shaking results compared to other bundlers for the following reasons:
Finer-grained analysis: It analyzes and removes code at the AST node level, while other bundlers typically operate at the top-level statement level or with even coarser granularity, leaving finer-grained dead code elimination (DCE) to minifiers.
More precise side effects analysis: Side effects are evaluated at the AST node level with context awareness, whereas other bundlers use coarser-grained, context-independent side effects analysis.
Rollup’s fine-grained approach inherently includes the coarser-grained analysis of export statements and top-level statements. As a result, Rollup can not only remove unused exports across modules but also handle some intra-module DCE. The following discussion will focus on intra-module DCE, using specific cases to illustrate these two points.
Rollup's Define feature is implemented differently from other bundlers. rollup-plugin-replace only replaces matched Define nodes during the transform phase, unlike webpack and esbuild, which perform replacements during the parse phase and also analyze dead branches. Code in dead branches is skipped during analysis, and no dependencies from dead branches are included in the module graph. However, this dead branch analysis during the parse phase cannot span across modules.
Since Rollup's tree shaking operates at the AST node level, even statement nodes inside functions can be analyzed for tree shaking. Therefore, Rollup delegates part of the analysis to the tree shaking phase, and the removal of dead branches also relies on tree shaking.
In this example, it attempts to perform compile-time evaluation on the DEVELOPMENT variable in if (DEVELOPMENT), which result is a constant, so the else branch can be removed as a dead branch. Additionally, the DEVELOPMENT variable in file.js is not marked as used, allowing the final tree shaking to remove the export const DEVELOPMENT declaration and the file.js module.
The downside of this approach is that dependencies introduced in dead branches are still added to the module graph, bringing in more modules and requiring Rollup to handle more work, which reduces performance.
The advantage is that it enables cross-module analysis and removal of dead branches, eliminating more unused code and resulting in a smaller output bundle. Other bundlers rely on scope hoisting to merge modules into a single scope and depend on the minifier to remove these cross-module dead branches. However, for modules where scope hoisting must be bailed out to ensure correct execution, there is no good solution to optimize these modules. Future optimizations may be needed to address this.
When analyzing console.log(obj.a.ab), Rollup marks this statement for include() due to its side effects. During include(obj.a.ab), it triggers the include() of related nodes, including the obj declaration node, the a: property node, and the ab: property node. Thanks to AST node-level tree shaking, Rollup can retain only the used a: and ab: properties while shaking off other unused properties, resulting in a smaller output bundle.
In this case, if you comment out a = {}, you'll notice all the code gets tree-shaken. However, once you uncomment it, the tree shaking no longer occurs. This is because Rollup determines whether a.b = 3 has side effects based on whether variable a is reassigned. This demonstrates Rollup's ability to perform some context-aware side effects analysis, though it does come with a certain performance overhead compared to context-free side effects analysis.
That said, this context-aware side effects analysis is relatively simple and typically only works for specific, straightforward scenarios. For example, in the above case, it only checks whether reassignment occurs but does not analyze whether the reassignment actually causes meaningful changes—meaning it avoids overly deep or detailed analysis.
Even if the same variable is reassigned without any actual changes, `a.b = 3` will still be considered to have side effects.
Rollup v3 only supported statement-level tree shaking, but starting with v4, it began experimenting with finer-grained AST node-level tree shaking (such as the object properties tree shaking mentioned above), as well as optimizing the tracking of unused nodes in specific scenarios, for example:
Tree shaking dead branches inside functions based on "constant parameters". This feature evolved from tree shaking function default parameters. When a function is called only once or multiple times with unchanged parameters using the same variable, it analyzes the variables used in the parameters and applies specific optimizations based on them.
This makes tree shaking possible in more scenarios:
@ahabhgk Have you folks looked at what the Parcel team is doing at all? I saw no mention of it in here, but it is worth investigating. They often come up with innovative and efficient solutions to problems.
Parcel does indeed feature many innovations designs, but I'm not particularly familiar Parcel compares to other bundlers, which is why I didn't mention it in this article.
All I know about Parcel's tree shaking is from their architecture docs, based on that I believe Parcel's tree shaking is at least fundamentally similar to webpack in principle (consists of module/export/code three level, and relies on scope hoisting to let minifier better optimize the code), though there are some implementation differences of course.
Definitely have read, I'm very excited to see Parcel's future progress on scope hoisting. It would be excellent if a simple yet effective solution could replace scope hoisting. Currently in Rspack, since we need to ensure correct module execution order (run side effects code correctly) while hoisting modules as more as possible, scope hoisting accounts for a big part of the build time.
Interesting, might be a big gain potential then. And my bad I just realised you actually linked Devon's post about hoisting so you ended had read it 🤦♂️. Regardless, just wanted to say thank you for the write up, I learned a lot.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Tree shaking has become an extremely important and indispensable part of modern front-end bundling. Given the differences in applicable scenarios and focus areas among various bundlers, their implementations of tree shaking also vary. For example, webpack is primarily used for front-end application bundling and emphasizes correctness, with its tree shaking focusing on cross-module-level optimizations. On the other hand, Rollup is mainly used for library bundling and prioritizes optimization efficiency, performing tree shaking at the granularity of AST nodes, which typically results in smaller bundle sizes. However, it may lack guarantees for the execution correctness of certain edge cases.
This article will provide a brief overview of the tree shaking principles of various bundlers and the differences between them.
Tree shaking in webpack / Rspack
webpack's tree shaking consists of three parts:
module-level:
optimization.sideEffectsRemove modules that have no used exports and no side effects.import "./module";No using any exports and have no side effects, so./modulecan be safely removed.re-exports.js(barrel file), which itself doesn't have any local exports is being used, and have no side effects, so re-exports.js can be safely removedexport-level:
optimization.providedExportsandoptimization.usedExports, to remove unused exportsoptimization.providedExportsto analyze what exports a module hasoptimization.usedExportsto analyze which exports of a module are actually used, unused exports can be removed during code generation:export const a = 42=>const a = 42, and then a minimizer (such as SWC or Terser) can further eliminate the remaining declaration if the variable is also unused within the module itselfcode-level:
optimization.minimizeUse minifiers like SWC or Terser to analyze code through techniques such as inlining and evaluation, removing dead code and performing compression to minimize bundle size as much as possible.optimization.minimizerplugin, performing post-processing on the bundler's output, which falls outside the bundler's core responsibilitiesIn addition, another important part is static analysis. Both module-level and export-level optimizations require webpack to perform static analysis on the code to determine whether a module has side effects, what exports it contains, and which exports are actually used. This information serves as input for these optimization phases.
Theoretically, as long as webpack's JavaScript parser can statically extract this information, tree shaking can be applied—even to CommonJS and dynamic imports, which are inherently dynamic. If these constructs follow a static pattern and the necessary information can be statically inferred, tree shaking remains possible.
However, currently, webpack only performs static analysis for very limited scenarios involving CJS and dynamic imports. Many analyzable cases remain unoptimized, leaving significant room for improvement.
After these three steps are completed, tree shaking is already functional, but there can still be issues with certain cases:
The export
ais referenced by another unused exportgin a different module, preventingafrom being tree-shaken. In this case,optimization.innerGraphis needed to analyze the dependency relationships between top-level statements inlib.js. Only when the top-level statement containingais used willabe marked as used; otherwise, it will be considered unused.Some optimizable cases by minifiers cannot be optimized due to webpack's runtime wrapping each module in a function. For example, in the following case:
Generally, this can be resolved via
optimization.concatenateModules, though in complex scenarios the number of modules eligible for concatenation tends to be quite limited.The minifier can effectively shorten variable names through mangling, but this wrapper function prevents it from mangling exported module variables. This is where
optimization.mangleExportscomes into play to handle the mangling.// output.js const __webpack_modules__ = { "./index.js": (__webpack_require__) => { const _module__WEBPACK_MODULE__ = __webpack_require__("./constants.js"); - console.log(_constants__WEBPACK_MODULE__.aVeryLongLongLongName ? 1 : 2); + console.log(_constants__WEBPACK_MODULE__.a ? 1 : 2); }, "./constants.js": (__webpack_require__) => { __webpack_require__.d({ - aVeryLongLongLongName: () => aVeryLongLongLongName, + a: () => aVeryLongLongLongName, }) const aVeryLongLongLongName = 42; } }Additional optimizations, such as experiments.inlineConst introduced in Rspack v1.4.
Tree shaking in esbuild
esbuild's tree shaking involves these steps:
IsLive = true.IsLive = trueare included; unmarked parts are removed.Splitting top-level statements in advance allows esbuild to inherently solve the innerGraph issue. After splitting, each top-level statement becomes a part, enabling analysis and optimization at the part-level granularity, unlike webpack which operates at the module-level granularity. This approach offers the following key advantages:
It's worth noting that early versions of esbuild also allowed these top-level statements to participate in code splitting, which is what we now call "module splitting". However, since esbuild does not wrap each module in a function like webpack's output—which separates module loading and execution—it struggled to properly handle top-level await. As a result, esbuild later dropped support for module splitting.
Currently, esbuild/docs/architecture/code-splitting still corresponds to the version that supports module splitting. Code splitting is performed at the part-level, with the shared chunk containing only the top-level statements common to both entry points. A replica in the esbuild playground shows that the shared chunk includes modules shared by both entries, with code splitting being done at the module level.
The result that before support module splitting
The result that now no longer support module splitting
Additionally, except top-level await, another complexity in esbuild's module splitting arose from not separating module loading and execution: ES Module's static imports are read-only. This meant esbuild couldn't move variable assignments into a different chunk from the variable's declaration during code splitting. After esbuild dropped module splitting, this issue no longer required special handling.
Tree shaking in Turbopack
Module splitting is actually more suitable for bundlers like webpack, which can separate module loading and execution, inherently ensuring the correctness of module execution without requiring additional handling of variable declarations and usage within modules.
Additionally, it allows applying more module-level optimizations to top-level statements inside modules, such as optimizations that esbuild lacks or underperforms in: code splitting, runtime optimization, chunk splitting, etc., enabling further optimizations.
So, is there a bundler that combines webpack-like runtime (separating module loading and execution) with support for module splitting? Yes: Turbopack.
First, let's examine how Turbopack's output format solves the issue of disallowing variable assignments and declarations across chunks, using the example from esbuild's bundling result:
Turbopack uses an output format similar to webpack, wrapping each module in a function to separate module loading and execution. But unlike webpack, the runtime
__turbopack_context__.sthat defines module exports not only provides a getter for exports but also includes an additional setter. When other parts of the module perform assignment operations on these variables, the corresponding setters are triggered to update the values, ensuring correct execution.For top-level await, like webpack, Turbopack employs a runtime to guarantee the correct execution order of modules containing top-level await and their imported dependencies. For example, after adding
await 1;to the first line of data.js, the bundled output is as follows:Of course, module splitting also has its drawbacks. After splitting, each top-level statement in the output is wrapped in a function. While this ensures correct execution, it amplifies the downsides of this wrapping approach:
_data_0__TURBOPACK_MODULE__.data, potentially degrading runtime performance (this still requires modern browser benchmarks for validation).Both issues necessitate greater reliance on scope hoisting for optimization.
Tree shaking in Rollup
If webpack performs tree shaking on export statements and modules, and esbuild does it on top-level statements, then Rollup conducts tree shaking from top to bottom for all statements and even finer-grained AST nodes, with more precise side effects detection.
Rollup performs tree shaking on statements and some finer-grained AST nodes.
Rollup's tree shaking is similar to esbuild's but with slight differences. The process works as follows:
include()on the module.a. Determine if the AST node has side effects.
b. If yes, call
include().c. Continue side effect checks and
include()on related AST nodes (repeating steps a and b).include()-ed. If so, trigger a new traversal (steps 1 and 2).Rollup often achieves better tree shaking results compared to other bundlers for the following reasons:
Rollup’s fine-grained approach inherently includes the coarser-grained analysis of export statements and top-level statements. As a result, Rollup can not only remove unused exports across modules but also handle some intra-module DCE. The following discussion will focus on intra-module DCE, using specific cases to illustrate these two points.
Rollup cross-module analysis to eliminate non-top-level dead branch:
Rollup's Define feature is implemented differently from other bundlers. rollup-plugin-replace only replaces matched Define nodes during the transform phase, unlike webpack and esbuild, which perform replacements during the parse phase and also analyze dead branches. Code in dead branches is skipped during analysis, and no dependencies from dead branches are included in the module graph. However, this dead branch analysis during the parse phase cannot span across modules.
Since Rollup's tree shaking operates at the AST node level, even statement nodes inside functions can be analyzed for tree shaking. Therefore, Rollup delegates part of the analysis to the tree shaking phase, and the removal of dead branches also relies on tree shaking.
In this example, it attempts to perform compile-time evaluation on the
DEVELOPMENTvariable inif (DEVELOPMENT), which result is a constant, so the else branch can be removed as a dead branch. Additionally, theDEVELOPMENTvariable infile.jsis not marked as used, allowing the final tree shaking to remove theexport const DEVELOPMENTdeclaration and thefile.jsmodule.The downside of this approach is that dependencies introduced in dead branches are still added to the module graph, bringing in more modules and requiring Rollup to handle more work, which reduces performance.
The advantage is that it enables cross-module analysis and removal of dead branches, eliminating more unused code and resulting in a smaller output bundle. Other bundlers rely on scope hoisting to merge modules into a single scope and depend on the minifier to remove these cross-module dead branches. However, for modules where scope hoisting must be bailed out to ensure correct execution, there is no good solution to optimize these modules. Future optimizations may be needed to address this.
Rollup removes unused object properties
When analyzing
console.log(obj.a.ab), Rollup marks this statement forinclude()due to its side effects. Duringinclude(obj.a.ab), it triggers theinclude()of related nodes, including theobjdeclaration node, thea:property node, and theab:property node. Thanks to AST node-level tree shaking, Rollup can retain only the useda:andab:properties while shaking off other unused properties, resulting in a smaller output bundle.Rollup determines side effects based on reassignment
In this case, if you comment out
a = {}, you'll notice all the code gets tree-shaken. However, once you uncomment it, the tree shaking no longer occurs. This is because Rollup determines whethera.b = 3has side effects based on whether variableais reassigned. This demonstrates Rollup's ability to perform some context-aware side effects analysis, though it does come with a certain performance overhead compared to context-free side effects analysis.That said, this context-aware side effects analysis is relatively simple and typically only works for specific, straightforward scenarios. For example, in the above case, it only checks whether reassignment occurs but does not analyze whether the reassignment actually causes meaningful changes—meaning it avoids overly deep or detailed analysis.
Even if the same variable is reassigned without any actual changes, `a.b = 3` will still be considered to have side effects.
Rollup v3 only supported statement-level tree shaking, but starting with v4, it began experimenting with finer-grained AST node-level tree shaking (such as the object properties tree shaking mentioned above), as well as optimizing the tracking of unused nodes in specific scenarios, for example:
This makes tree shaking possible in more scenarios:
All reactions