Removing Dead Code with JS Tree Shaking Guide

Learn to remove dead code with JavaScript tree shaking. Covers identifying dead code patterns, conditional compilation, environment-based elimination, dynamic import analysis, debugging tree shaking failures, and measuring dead code reduction.

JavaScriptadvanced
16 min read

Dead code inflates bundles, slows load times, and increases attack surface. This guide focuses on practical techniques for identifying and eliminating dead code beyond basic tree shaking.

For tree shaking fundamentals and ES module analysis, see JavaScript Tree Shaking: A Complete Tutorial.

Identifying Dead Code

javascriptjavascript
// Dead code pattern 1: Unreachable code after return
function processData(data) {
  if (!data) return null;
  return transform(data);
  console.log("This never executes"); // Dead code
  cleanup(); // Dead code
}
 
// Dead code pattern 2: Unused variables and imports
import { formatDate, formatCurrency, slugify } from "./utils";
// Only formatDate is used below
const unused = "this string is never read"; // Dead code
console.log(formatDate(new Date()));
 
// Dead code pattern 3: Never-true conditions
const DEBUG = false;
if (DEBUG) {
  // This entire block is dead code in production
  console.log("Debug info:", internalState);
  window.__DEBUG_PANEL__ = createDebugPanel();
}
 
// Dead code pattern 4: Unused class methods
class DataService {
  fetch(url) { /* used */ }
  cache(key, value) { /* used */ }
  deprecated_legacyFetch(url) { /* never called anywhere */ }
  internal_debugLog(msg) { /* never called anywhere */ }
}

Environment-Based Dead Code Elimination

javascriptjavascript
// Webpack DefinePlugin replaces expressions at build time
// webpack.config.js
const webpack = require("webpack");
 
module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("production"),
      "__DEV__": false,
      "__FEATURE_ANALYTICS__": true,
      "__FEATURE_EXPERIMENTS__": false,
    }),
  ],
};
 
// Source code with feature flags
if (__DEV__) {
  // Entire block removed in production build
  enableHotReload();
  installDevTools();
  window.__APP_STATE__ = appState;
}
 
if (__FEATURE_EXPERIMENTS__) {
  // Removed when feature flag is false
  import("./experiments/ABTestRunner").then((mod) => mod.init());
}
 
// After DefinePlugin replacement:
if (false) {
  enableHotReload(); // Dead code, removed by minifier
}
 
// Conditional exports based on environment
export function log(message) {
  if (process.env.NODE_ENV !== "production") {
    console.log(`[LOG] ${message}`);
  }
  // In production: function body is empty, may be eliminated entirely
}

Dead Code Detection Script

javascriptjavascript
// Script to find potentially unused exports
const fs = require("fs");
const path = require("path");
 
class DeadCodeDetector {
  constructor(srcDir) {
    this.srcDir = srcDir;
    this.exports = new Map();
    this.imports = new Map();
  }
 
  scan() {
    const files = this.getJSFiles(this.srcDir);
 
    // Phase 1: Collect all exports
    for (const file of files) {
      const content = fs.readFileSync(file, "utf8");
      this.extractExports(file, content);
      this.extractImports(file, content);
    }
 
    // Phase 2: Find unused exports
    return this.findUnused();
  }
 
  extractExports(file, content) {
    const patterns = [
      /export\s+(?:function|class|const|let|var)\s+(\w+)/g,
      /export\s+\{\s*([^}]+)\s*\}/g,
    ];
 
    const fileExports = [];
 
    for (const pattern of patterns) {
      let match;
      while ((match = pattern.exec(content)) !== null) {
        const names = match[1].split(",").map((n) =>
          n.trim().split(/\s+as\s+/).pop().trim()
        );
        fileExports.push(...names);
      }
    }
 
    if (fileExports.length > 0) {
      this.exports.set(file, fileExports);
    }
  }
 
  extractImports(file, content) {
    const pattern = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
    let match;
 
    while ((match = pattern.exec(content)) !== null) {
      const names = match[1].split(",").map((n) =>
        n.trim().split(/\s+as\s+/)[0].trim()
      );
      const source = match[2];
 
      if (!this.imports.has(source)) {
        this.imports.set(source, new Set());
      }
      names.forEach((n) => this.imports.get(source).add(n));
    }
  }
 
  findUnused() {
    const unused = [];
 
    for (const [file, exports] of this.exports) {
      const relativePath = "./" + path.relative(this.srcDir, file)
        .replace(/\\/g, "/")
        .replace(/\.(js|ts|jsx|tsx)$/, "");
 
      for (const name of exports) {
        let isUsed = false;
 
        for (const [importPath, importedNames] of this.imports) {
          if (
            importPath === relativePath ||
            importPath === relativePath.replace("./", "../")
          ) {
            if (importedNames.has(name)) {
              isUsed = true;
              break;
            }
          }
        }
 
        if (!isUsed) {
          unused.push({ file, export: name });
        }
      }
    }
 
    return unused;
  }
 
  getJSFiles(dir) {
    const files = [];
    const entries = fs.readdirSync(dir, { withFileTypes: true });
 
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory() && entry.name !== "node_modules") {
        files.push(...this.getJSFiles(fullPath));
      } else if (/\.(js|ts|jsx|tsx)$/.test(entry.name)) {
        files.push(fullPath);
      }
    }
 
    return files;
  }
}
 
// Usage
const detector = new DeadCodeDetector("./src");
const unused = detector.scan();
console.table(unused);

Debugging Tree Shaking Failures

javascriptjavascript
// Technique 1: Check if a module is being included
// Add this to the suspect module:
console.log("MODULE INCLUDED:", import.meta.url);
// If you see this in production, tree shaking failed
 
// Technique 2: Webpack stats analysis
// webpack.config.js
module.exports = {
  stats: {
    usedExports: true,
    providedExports: true,
    optimizationBailout: true, // Shows WHY tree shaking failed
  },
};
 
// Run with: webpack --json > stats.json
// Look for "optimizationBailout" messages
 
// Technique 3: Manual side effect check
function checkSideEffects(modulePath) {
  const content = fs.readFileSync(modulePath, "utf8");
 
  const sideEffectPatterns = [
    { pattern: /window\.\w+\s*=/, desc: "Global assignment" },
    { pattern: /document\.\w+/, desc: "DOM access" },
    { pattern: /console\.\w+\(/, desc: "Console output" },
    { pattern: /fetch\s*\(/, desc: "Network request" },
    { pattern: /addEventListener\s*\(/, desc: "Event listener" },
    { pattern: /\.prototype\.\w+\s*=/, desc: "Prototype modification" },
    { pattern: /setInterval|setTimeout/, desc: "Timer" },
  ];
 
  const topLevelCode = extractTopLevelStatements(content);
  const issues = [];
 
  for (const { pattern, desc } of sideEffectPatterns) {
    if (pattern.test(topLevelCode)) {
      issues.push(desc);
    }
  }
 
  return {
    hasSideEffects: issues.length > 0,
    issues,
    safe: issues.length === 0,
  };
}
 
function extractTopLevelStatements(code) {
  // Simplified: remove function/class bodies
  // Real implementation would use an AST parser
  return code
    .replace(/(?:function|class)\s+\w+[^{]*\{[^}]*\}/gs, "")
    .replace(/export\s+(function|class)/g, "$1");
}

Pure Annotations and IIFE Patterns

javascriptjavascript
// #__PURE__ tells bundlers a function call has no side effects
// It is safe to remove if the result is unused
 
// Library code
export const logger = /* #__PURE__ */ createLogger("app");
export const store = /* #__PURE__ */ createStore(initialState);
export const theme = /* #__PURE__ */ computeTheme(defaultConfig);
 
// If logger, store, or theme are never imported,
// the createLogger/createStore/computeTheme calls are removed
 
// IIFE pattern (Immediately Invoked Function Expression)
// BAD: IIFE at top level is always a side effect
const config = (() => {
  const env = detectEnvironment();
  return { debug: env === "dev", apiUrl: env === "prod" ? PROD_URL : DEV_URL };
})();
 
// GOOD: Mark IIFE as pure
const config = /* #__PURE__ */ (() => {
  const env = detectEnvironment();
  return { debug: env === "dev" };
})();
 
// Terser/uglify also supports @__PURE__
const instance = /** @__PURE__ */ new HeavyClass();

Measuring Dead Code Reduction

javascriptjavascript
// Build size comparison script
const { execSync } = require("child_process");
const fs = require("fs");
 
function measureBuildSize(config) {
  execSync(`webpack --config ${config}`, { stdio: "pipe" });
 
  const distDir = "./dist";
  const files = fs.readdirSync(distDir).filter((f) => f.endsWith(".js"));
 
  let totalSize = 0;
  let totalGzip = 0;
 
  const details = files.map((file) => {
    const filePath = `${distDir}/${file}`;
    const stat = fs.statSync(filePath);
    const gzipSize = execSync(`gzip -c "${filePath}" | wc -c`, {
      encoding: "utf8",
    }).trim();
 
    totalSize += stat.size;
    totalGzip += parseInt(gzipSize);
 
    return {
      file,
      rawKB: (stat.size / 1024).toFixed(1),
      gzipKB: (parseInt(gzipSize) / 1024).toFixed(1),
    };
  });
 
  return { details, totalKB: (totalSize / 1024).toFixed(1), totalGzipKB: (totalGzip / 1024).toFixed(1) };
}
 
// Compare with and without tree shaking
const withShaking = measureBuildSize("webpack.prod.js");
const withoutShaking = measureBuildSize("webpack.no-shake.js");
 
console.log("With tree shaking:", withShaking.totalKB, "KB");
console.log("Without tree shaking:", withoutShaking.totalKB, "KB");
const saved = (
  ((withoutShaking.totalKB - withShaking.totalKB) / withoutShaking.totalKB) * 100
).toFixed(1);
console.log(`Reduction: ${saved}%`);
Dead Code SourceDetection MethodRemoval Strategy
Unused exportsBundle analyzer, dead code detectorTree shaking with sideEffects: false
Debug-only codeFeature flags, DefinePluginConditional compilation
Legacy compatibilityUsage analysis, browser targetsbrowserslist + @babel/preset-env
Unused dependenciesdepcheck, npm-checkRemove from package.json
Unreachable branchesStatic analysis, ESLint rulesMinifier dead code elimination
Polyfills for modern browserscore-js usage analysisuseBuiltIns: "usage" in Babel
Rune AI

Rune AI

Key Insights

  • Side effects at the top level prevent tree shaking: Move all executable code inside exported functions; never run code on module import unless it is genuinely required
  • DefinePlugin enables conditional dead code removal: Replace feature flags with literal booleans at build time so the minifier can eliminate entire code branches
  • Dead code detection scripts find unused exports: Scan your codebase to identify exports that no module imports, then safely remove them
  • #PURE annotations mark safe-to-remove function calls: Without this annotation, bundlers assume factory function calls have side effects and keep them
  • Measure before and after with bundle analysis: Use webpack-bundle-analyzer to visualize what is actually in your bundle and verify that tree shaking is working as expected
RunePowered by Rune AI

Frequently Asked Questions

How much bundle size can tree shaking typically save?

For applications importing large utility libraries (lodash, date-fns, Material UI), tree shaking can reduce bundle size by 30-70%. For well-structured applications with few large dependencies, savings are typically 10-20%. The biggest wins come from libraries that export hundreds of functions where only a few are used.

Why does my dead code still appear in the bundle?

Common causes: the module has side effects at the top level (global assignments, console.log, DOM manipulation on import), the package.json lacks `"sideEffects": false`, Babel is converting ES modules to CommonJS before the bundler can analyze them, or the import pattern uses a default export object that cannot be partially eliminated.

Should I use lodash or lodash-es for tree shaking?

Use `lodash-es` which ships ES modules, making each function individually tree-shakable. Standard `lodash` uses CommonJS and cannot be tree-shaken. Alternatively, use direct imports like `import debounce from "lodash/debounce"` with standard lodash. With `lodash-es` and proper tree shaking, importing 5 functions from a 70KB library results in only those 5 functions (typically 2-5KB) in the bundle.

How do feature flags interact with tree shaking?

Feature flags defined via DefinePlugin or import.meta.env are replaced at build time with literal values (true/false). The minifier then removes `if (false)` branches as dead code. This is not technically tree shaking (which operates on exports) but is complementary dead code elimination. Both techniques together provide maximum bundle reduction.

Can tree shaking remove unused CSS?

Tree shaking specifically applies to JavaScript module exports. For CSS, use PurgeCSS or Tailwind's built-in purging, which scan HTML/JS for used class names and remove unused CSS rules. Some CSS-in-JS solutions benefit from JS tree shaking since styles are defined as JavaScript exports.

Conclusion

Removing dead code requires a multi-pronged approach: tree shaking for unused exports, DefinePlugin for environment-specific code, and dead code detection scripts for legacy/unreachable code. Always configure sideEffects: false in package.json and keep Babel from converting ES modules. For tree shaking fundamentals, see JavaScript Tree Shaking: A Complete Tutorial. For bundler configuration, see JavaScript Bundlers: An Advanced Architecture.