| | |

Parcel 19 ๐Ÿ“ฆ Tree Shaking โ€” Scope hoisting & dead code elimination

โฑ๏ธ Estimated read: 12 min | |

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

PatternEffectFix
eval()Module wrapped, tree shaking disabledUse Function constructor
Top-level returnModule wrappedRemove the return
module/exports re-assignmentExports unknowableUse static exports
Dynamic property access on importAll exports includedUse static access
Dynamic require()Module wrappedUse static require
Conditional require()Module wrappedUse static import

The sideEffects field

ValueMeaningEffect
falseNo side effectsAssets skipped, exports tree-shaken
["*.css"]Only CSS has side effectsJS tree-shaken, CSS retained
absentAll files side-effectfulNo skipping

Diagnostics

CommandOutput
--log-level verboseBailout 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

PitfallWhy It HappensFix
Unused export is retainedModule has a bailout patternCheck verbose log
CSS is removed unexpectedlysideEffects: false in a package with CSSUse ["*.css"]
Tree shaking works in one build but not anotherScope hoisting disabled (e.g., RSC)Check the build mode
/*#__PURE__*/ has no effectThe declaration is usedThe 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

ItemValue
Tree shaking triggerProduction build (automatic)
Scope hoistingConcatenates modules into a single scope
Bailout patternseval, top-level return, re-assignment, dynamic access
Diagnostics--log-level verbose
sideEffects: falseSkips 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.foo assignment is a side effect that the minifier cannot remove .
  • Dynamic patterns cause bailouts. eval, top-level return, re-assignment of module/exports, and dynamic property access all force the module into a wrapper and disable tree shaking. The --log-level verbose flag reports every bailout with a code frame .
  • The sideEffects field skips assets before they are processed. A package that declares "sideEffects": false allows 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 sideEffects declaration produces silent breakage. A package that declares false but 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.

Was this helpful?


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!