Parcel 19 ๐ฆ Tree Shaking โ Scope hoisting & dead code elimination
Parcel’s tree shaking is not a separate optimization pass that runs after bundling. It is the result of two mechanisms working together: symbol propagation, which determines which exports are actually used, and scope hoisting, which concatenates modules into a single scope so that the minifier can see and remove the unused declarations. The distinction matters because scope hoisting is what makes the removal effective. Without it, each module is wrapped in a function, and the minifier cannot prove that an unused export inside a closure is safe to remove. With it, the modules share a top-level scope, the unused export becomes an ordinary unused variable, and the minifier drops it .
The documentation describes the mechanism precisely: Parcel statically analyzes imports and exports, removes everything that is not used, and concatenates modules into a single scope when possible . The concatenation is the scope hoisting. The removal is the tree shaking. They are coupled because the removal depends on the concatenation. A module that cannot be hoistedโbecause it uses eval, has a top-level return, or accesses its exports dynamicallyโis wrapped in a function, and tree shaking is disabled for that module .
Key point: Scope hoisting concatenates modules into a single scope, which turns unused exports into unused variables that the minifier can remove. When Parcel cannot hoist a module because of dynamic patterns, the module is wrapped, and tree shaking is disabled for it. The --log-level verbose flag reports every bailout.
Why scope hoisting is the prerequisite for effective tree shaking
The wrapped-module problem. In a wrapped-module bundle, each module is a function that receives module and exports objects. The exports are properties on that object, accessed via module.exports.foo. A minifier cannot remove exports.foo = function() {} because it cannot prove that no other code will read exports.foo at runtime. The property assignment is a side effect on the exports object, and the minifier must preserve it. This is why naive bundlers retain unused exports even when the source code never imports them .
The concatenation solution. Scope hoisting removes the function wrappers. The exports become top-level variables with unique names like $fa6943ce8a6b29$export$square. An unused export is now an unused variable declaration. The minifier sees function $fa6943ce8a6b29$export$square(a) { return a * a; } and nothing references it, so it removes it. The removal is trivial because the scope is flat and the references are static . The example in the documentation shows exactly this: the add function is renamed and kept, the square function is removed because it is unused .
The bailout conditions. The hoisting fails when Parcel cannot statically analyze the module’s structure. The documentation lists the patterns: eval (which could access any variable in scope), top-level return (which would stop the entire bundle instead of just the module), re-assignment of module or exports (which makes the export names unknowable), and dynamic property access on imports (which requires an exports object at runtime) . Each of these forces the module into a wrapper, and the wrapper disables the tree shaking for that module. The --log-level verbose flag prints a diagnostic for each bailout, including a code frame showing the offending line .
The trade-off. Scope hoisting produces smaller and faster output, but it also makes the build slower and more memory-intensive. The packager must modify every statement and keep the entire bundle in memory during packaging, because the modules are concatenated rather than copied as separate strings . The default production build enables it because the output size and runtime performance are the priorities. The development build does not use it, because the build speed and HMR are the priorities .
a. Reading the hoisted output and the /*#__PURE__*/ annotation
The hoisted output is readable if you know what to look for. The module boundaries disappear, the exports become top-level variables, and the unused ones are removed by the minifier.
// math.js โ source
export function add(a, b) {
return a + b;
}
export function square(a) {
return a * a;
}
// index.js โ source
import { add } from './math.js';
console.log(add(2, 3));
// Output after scope hoisting and minification
// The add function is renamed and kept.
function $fa6943ce8a6b29$export$add(a, b) {
return a + b;
}
// The square function is removed because it is unused.
// (The minifier drops it after the export is removed.)
console.log($fa6943ce8a6b29$export$add(2, 3));
The /*#__PURE__*/ annotation is a separate mechanism that helps the minifier in a different case. When a function call is marked pure, the minifier knows that the call has no side effects and can remove it if the result is unused. This is important for library code that creates objects at the top level.
// Without the pure annotation, the minifier cannot remove the call
// even if MyContext is unused, because the call might have side effects.
const MyContext = React.createContext();
// With the pure annotation, the minifier can remove the entire declaration
// if MyContext is unused.
const MyContext = /*#__PURE__*/ React.createContext();
The documentation notes that Babel and SWC add this comment automatically when transpiling JSX, so the pattern is usually handled by the toolchain rather than written by hand . The annotation is only relevant when the declaration is unused; if the context is used, the annotation has no effect.
b. The sideEffects field and the skipping of assets
The sideEffects field in package.json is a separate signal that allows Parcel to skip assets entirely, before the hoisting pass runs. When a package declares "sideEffects": false, Parcel can skip processing assets that are only imported for their re-exports, because it knows those assets have no side effects that need to run . This is different from tree shaking the exports of a module that is included; it is skipping the inclusion of the module altogether.
// package.json โ a side-effect-free package
{
"name": "my-lib",
"sideEffects": false
}
The documentation describes the benefit: import { Button } from "design-system" would only process the export { Button } from "./button" re-export and completely ignore all other exports in the design system’s index file . The other components are not just tree-shaken; they are never transformed in the first place. This is the “deferring” behavior, and it applies even when scope hoisting is disabled.
The sideEffects field is a declaration, and a false declaration produces silent breakage. A package that declares "sideEffects": false but has a top-level CSS import or a global assignment will have that code removed, because Parcel trusts the declaration. The array form ("sideEffects": ["*.css"]) is the correct pattern for packages that have some side-effectful files and some pure ones .
Complete Example Session
// ============================================
// PART 1: THE SOURCE WITH A SHARED MODULE
// ============================================
// src/math.js
export function add(a, b) { return a + b; }
export function square(a) { return a * a; }
// src/index.js
import { add } from './math.js';
console.log(add(2, 3));
// ============================================
// PART 2: THE HOISTED OUTPUT
// ============================================
// After production build with scope hoisting:
// The module wrappers are removed.
// The exports become top-level variables with unique names.
// The unused `square` is removed by the minifier.
// function $fa6943ce8a6b29$export$add(a, b) {
// return a + b;
// }
// console.log($fa6943ce8a6b29$export$add(2, 3));
// ============================================
// PART 3: THE BAILOUT โ eval
// ============================================
// src/math.js โ with eval
export function add(a, b) { return a + b; }
export function square(a) { return a * a; }
eval('console.log("side effect")');
// The eval call forces the module to be wrapped.
// Tree shaking is disabled for this module.
// Both add and square are retained.
// ============================================
// PART 4: THE BAILOUT โ DYNAMIC PROPERTY ACCESS
// ============================================
// src/app.js
import * as math from './math.js';
const name = 'add';
const result = math[name](2, 3); // dynamic access
// Parcel cannot statically resolve which export is used.
// All exports of math.js are included.
// An exports object is created for runtime resolution.
// ============================================
// PART 5: THE BAILOUT โ COMMONJS RE-ASSIGNMENT
// ============================================
// src/legacy.js
module.exports = { add: (a, b) => a + b };
module.exports = { square: (a) => a * a }; // re-assignment
// The re-assignment makes the exports unknowable at build time.
// The module is wrapped and tree shaking is disabled.
// ============================================
// PART 6: THE sideEffects FIELD
// ============================================
// package.json โ a side-effect-free library
{
"name": "my-lib",
"sideEffects": false
}
// When another package imports { Button } from 'my-lib',
// Parcel skips processing all other modules in my-lib
// that are only re-exports. They are never transformed.
// ============================================
// PART 7: THE sideEffects ARRAY
// ============================================
// package.json โ a library with CSS
{
"name": "my-ui",
"sideEffects": ["*.css"]
}
// JavaScript modules are tree-shakeable.
// CSS imports are retained because they have side effects.
// ============================================
// PART 8: THE VERBOSE DIAGNOSTICS
// ============================================
// Run with verbose logging to see bailouts:
// parcel build src/index.html --log-level verbose
//
// The output prints a diagnostic for each bailout,
// including the file and the line that caused it.
// ============================================
// PART 9: THE PURE ANNOTATION
// ============================================
// src/context.js
// Without the pure annotation, the minifier cannot remove this
// even if the context is unused.
export const MyContext = React.createContext();
// With the pure annotation, the minifier can remove it.
export const MyContext = /*#__PURE__*/ React.createContext();
// Babel and SWC add this automatically for JSX.
// ============================================
// PART 10: VERIFICATION
// ============================================
// 1. Build: parcel build src/index.html
// 2. Inspect the output: search for the name of the unused export.
// 3. If it appears, check the verbose log for bailouts.
// 4. If no bailout is reported, check the sideEffects field.
// 5. Remove the bailout pattern and rebuild.
// 6. Verify the unused export is gone.
The ten parts covered the hoisted output, the bailout conditions, the sideEffects field, the verbose diagnostics, and the pure annotation. The through-line is that tree shaking is a static analysis, and every dynamic pattern forces a bailout.
Quick Reference
Bailout conditions
| Pattern | Effect | Fix |
|---|---|---|
eval() | Module wrapped, tree shaking disabled | Use Function constructor |
Top-level return | Module wrapped | Remove the return |
module/exports re-assignment | Exports unknowable | Use static exports |
| Dynamic property access on import | All exports included | Use static access |
Dynamic require() | Module wrapped | Use static require |
Conditional require() | Module wrapped | Use static import |
The sideEffects field
| Value | Meaning | Effect |
|---|---|---|
false | No side effects | Assets skipped, exports tree-shaken |
["*.css"] | Only CSS has side effects | JS tree-shaken, CSS retained |
| absent | All files side-effectful | No skipping |
Diagnostics
| Command | Output |
|---|---|
--log-level verbose | Bailout diagnostics with code frames |
Best Practices
โ Do This:
// Use static access
import { add } from './math.js'; add(2, 3); // โ
// Use the sideEffects field for libraries
"sideEffects": false // โ
// Use the pure annotation for top-level calls
const Ctx = /*#__PURE__*/ React.createContext(); // โ
// Run verbose diagnostics when tree shaking fails
parcel build src/index.html --log-level verbose // โ
โ Don’t Do This:
// Use eval
eval('code'); // โ
// Use dynamic access
math[name](2, 3); // โ
// Re-assign module.exports
module.exports = {}; module.exports = {}; // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Unused export is retained | Module has a bailout pattern | Check verbose log |
| CSS is removed unexpectedly | sideEffects: false in a package with CSS | Use ["*.css"] |
| Tree shaking works in one build but not another | Scope hoisting disabled (e.g., RSC) | Check the build mode |
/*#__PURE__*/ has no effect | The declaration is used | The annotation only helps unused declarations |
Real-World Examples
1. Hoisted output
function $fa6943ce8a6b29$export$add(a, b) { return a + b; }
console.log($fa6943ce8a6b29$export$add(2, 3));
2. Bailout from eval
eval('x'); // module wrapped, tree shaking disabled
3. Bailout from dynamic access
math[name]; // all exports included
4. Bailout from re-assignment
module.exports = {}; module.exports = {}; // wrapped
5. sideEffects false
"sideEffects": false
6. sideEffects array
"sideEffects": ["*.css"]
7. Verbose diagnostics
parcel build src/index.html --log-level verbose
8. Pure annotation
const Ctx = /*#__PURE__*/ React.createContext();
9. Static access
import { add } from './math.js';
10. Top-level return bailout
return; // module wrapped
Visual
Scope Hoisting and Tree Shaking
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WRAPPED VS HOISTED โ
โ โ
โ Wrapped (development) Hoisted (production) โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ function(module, โ โ function $id$add(a,b)โ โ
โ โ exports, require) {โ โ { return a + b; } โ โ
โ โ exports.add = ... โ โ โ โ
โ โ exports.square=...โ โ // square is not โ โ
โ โ } โ โ // referenced, so โ โ
โ โ โ โ // minifier drops itโ โ
โ โ Minifier cannot โ โ โ โ
โ โ remove exports.squareโ โ Output is smaller. โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Bailout Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHY A MODULE IS WRAPPED โ
โ โ
โ Does the module contain eval? โโโถ Yes โโโถ WRAPPED โ
โ Does it have top-level return? โโโถ Yes โโโถ WRAPPED โ
โ Are exports re-assigned? โโโถ Yes โโโถ WRAPPED โ
โ Is there dynamic access? โโโถ Yes โโโถ WRAPPED โ
โ โ
โ Otherwise โโโถ HOISTED โโโถ Tree shaking possible โ
โ โ
โ Run --log-level verbose to see which pattern caused it. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The sideEffects Field
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SIDE EFFECTS DECLARATION โ
โ โ
โ "sideEffects": false โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ All files pure โ โ
โ โ โ skipped if unused โ โ
โ โ โ tree-shaken โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ "sideEffects": ["*.css"] โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ JS pure, CSS not โ โ
โ โ โ JS tree-shaken โ โ
โ โ โ CSS retained โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ A false declaration removes code that should have run. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Tree shaking trigger | Production build (automatic) |
| Scope hoisting | Concatenates modules into a single scope |
| Bailout patterns | eval, top-level return, re-assignment, dynamic access |
| Diagnostics | --log-level verbose |
sideEffects: false | Skips pure assets entirely |
sideEffects: ["*.css"] | Retains CSS, tree-shakes JS |
| Pure annotation | /*#__PURE__*/ for top-level calls |
Key takeaways:
- Scope hoisting is what makes tree shaking effective. The concatenation removes the module wrappers, turning unused exports into unused top-level variables that the minifier can drop. Without hoisting, the wrapper’s
exports.fooassignment is a side effect that the minifier cannot remove . - Dynamic patterns cause bailouts.
eval, top-levelreturn, re-assignment ofmodule/exports, and dynamic property access all force the module into a wrapper and disable tree shaking. The--log-level verboseflag reports every bailout with a code frame . - The
sideEffectsfield skips assets before they are processed. A package that declares"sideEffects": falseallows Parcel to ignore modules that are only re-exports, without transforming them at all. This is different from tree shaking a module that is included . - A false
sideEffectsdeclaration produces silent breakage. A package that declaresfalsebut has CSS imports or global assignments will have that code removed. The array form is the correct pattern for mixed packages . - The pure annotation helps the minifier in a specific case. A top-level call marked
/*#__PURE__*/can be removed if its result is unused. Babel and SWC add this automatically for JSX, so the pattern is usually handled by the toolchain .
Remember: Parcel’s tree shaking is not a single pass. It is the combination of symbol propagation (which determines what is used) and scope hoisting (which makes the unused code removable). The hoisting is the prerequisite. When it failsโbecause of eval, dynamic access, or re-assignmentโthe module is wrapped, and the tree shaking stops at that module. The fix is not a configuration flag; it is making the code statically analyzable. The --log-level verbose flag tells you which pattern caused the bailout. The sideEffects field tells Parcel which assets it can skip entirely. Both are declarations, and both must be accurate.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!