Eliminating Dead Code with Modern Build Tools
Without systematic dead code elimination, a typical React or Vue application ships 25–40% more JavaScript than it executes. That surplus code taxes the browser’s parse-and-compile pipeline before a single user interaction occurs, adding 300–700 ms to Time to Interactive on mid-range mobile devices and inflating compressed transfer sizes by 50–120 KB. Dead code elimination (DCE) in modern frontend pipelines is a deterministic, compile-time process — not a post-compilation heuristic — and skipping it has a measurable, reproducible cost.
This page covers the configuration, framework integration, pitfall diagnosis, and CI enforcement for DCE in Webpack 5 and Vite 5+. It sits within the broader Advanced Tree-Shaking & Dependency Optimization strategy, where DCE is the final pruning step after the module graph has been resolved and annotated.
How Dead Code Elimination Actually Works
Modern bundlers eliminate dead code in two sequential passes:
- Tree-shaking pass — the bundler traverses the module graph and marks every export binding as “used” or “unused” based on static
importreferences. This pass operates on ES module syntax only;require()calls are opaque to it. - DCE pass — the minifier (Terser in Webpack, esbuild in Vite) receives the annotated AST and physically removes unused bindings, unreachable branches, and dead assignments.
Both passes must succeed for dead code to be removed. A failure at step 1 (e.g. a CommonJS dependency) prevents step 2 from running on that module, regardless of minifier settings.
Architectural Context
DCE is the downstream beneficiary of every upstream decision in the Advanced Tree-Shaking & Dependency Optimization pipeline. The accuracy of the tree-shaking pass depends on correct sideEffects declarations — incorrectly marked packages force bundlers to retain exports defensively, sabotaging the DCE pass before it starts. Equally, refactoring barrel files is a prerequisite: a barrel re-export (export * from './utils') forces the bundler to treat every utility as potentially used, neutralizing export-level pruning.
Bundler-Specific Configuration
Webpack 5
// webpack.config.js — Webpack 5
module.exports = {
mode: 'production', // Enables usedExports + minimization by default
optimization: {
usedExports: true, // Mark unused exports with /*#__PURE__*/ comments
sideEffects: true, // Respect package.json "sideEffects" declarations
concatenateModules: true, // Scope hoisting: flatten module IIFEs before minification
providedExports: true, // Track which exports each module provides
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
// Remove console.log and debug helpers in production
pure_funcs: ['console.log', 'console.debug'],
passes: 2 // Two compression passes for deeper dead branch removal
}
}
})
]
}
};usedExports causes Webpack to annotate unused exports with /* unused harmony export */ comments in the intermediate output, which Terser then removes during minification. Without concatenateModules, each module is wrapped in its own IIFE, making it impossible for Terser to see cross-module dead branches.
Vite 5+ (Rollup back-end)
// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';
export default defineConfig({
build: {
minify: 'esbuild', // Default; swap to 'terser' for pure_funcs control
rollupOptions: {
treeshake: {
moduleSideEffects: 'no-external', // Treat all npm packages as side-effect-free
propertyReadSideEffects: false, // obj.prop reads don't prevent pruning
tryCatchDeoptimization: false // try/catch blocks don't block DCE
}
}
}
});Vite’s Rollup back-end performs tree-shaking during the transform hook — earlier in the pipeline than Webpack’s post-chunk approach. Setting moduleSideEffects: 'no-external' is the single highest-impact toggle: it tells Rollup to assume every package you import from node_modules is safe to prune unless the package’s own package.json says otherwise.
Side-by-side comparison
| Dimension | Webpack 5 | Vite 5+ |
|---|---|---|
| DCE phase | Post-chunk (Terser at emit) | Transform hook (Rollup, pre-chunk) |
| Scope hoisting | concatenateModules: true |
Automatic in Rollup |
| Minifier default | Terser | esbuild |
| Key toggle | usedExports: true |
treeshake.moduleSideEffects: 'no-external' |
| CJS interop | @rollup/plugin-commonjs or native |
@rollup/plugin-commonjs via vite.plugins |
Framework Integration
React
React’s production build already strips development-only code when process.env.NODE_ENV is statically replaced with "production". The DCE pass then removes entire branches like if (process.env.NODE_ENV !== 'production') { ... }. Two common gaps:
// react — ensure NODE_ENV replacement is in scope
// webpack.config.js — Webpack 5
const { DefinePlugin } = require('webpack');
module.exports = {
plugins: [
new DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
})
]
};For React Server Components, mark server-only modules via the package.json exports field with a react-server condition so the client bundle never receives them:
// package.json exports field for a server-only utility
{
"exports": {
".": {
"react-server": "./src/server.js",
"default": "./src/client.js"
}
}
}Using React.lazy and Suspense for route-level splitting keeps each route’s component tree out of the initial bundle, which reduces the surface area the DCE pass must evaluate at startup.
Vue 3
Vue’s template compiler eliminates unused directive code and tree-shakes Vue’s own internal helpers automatically in production mode. Configure @vitejs/plugin-vue to activate production optimizations:
// vite.config.ts — Vite 5+ with Vue 3
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
isProduction: true, // Strips dev warnings + enables template DCE
features: {
optionsAPI: false // Drop Options API runtime if you only use Composition API
}
})
]
});optionsAPI: false removes the entire Options API runtime (~12 KB pre-gzip) when you confirm your codebase uses only the Composition API.
Svelte
Svelte compiles each component to imperative DOM operations at build time. Unused reactive statements ($: unusedVar = ...) are eliminated by the Svelte compiler itself before Rollup sees the output. However, utility imports from non-Svelte packages still require standard ESM tree-shaking. Avoid barrel exports in Svelte component libraries and use direct named exports per file.
Quantified Impact
- Payload reduction: Applications that fully configure DCE (usedExports + sideEffects + scope hoisting) consistently see 22–35% smaller JS bundles after gzip compared to production builds with these settings absent.
- Parse time: Removing 100 KB of parsed JavaScript saves approximately 80–120 ms of CPU parse time on a mid-range Android device (Snapdragon 665-class), translating directly to lower Time to Interactive.
- Vue runtime savings: Disabling the Options API runtime in a Composition-API-only codebase removes ~12 KB gzipped from every route that loads the Vue runtime.
- React dev code: Replacing
process.env.NODE_ENVstatically in Webpack removes 15–25 KB gzipped from the React and ReactDOM packages (prop-types validation, stack trace generation, dev warnings). - CI regression prevention: Teams that enforce a dead-code ratio gate below 4% catch accidental side-effect annotation regressions within the same PR that introduced them, rather than at a quarterly audit.
Common Pitfalls
CJS package blocks entire subtree
Root cause: A single require() call anywhere in a module’s dependency chain makes that entire module opaque to the tree-shaking pass. Webpack and Rollup cannot statically determine what a dynamic property access like module.exports[key] exports.
Diagnostic signal: Run webpack --json=dist/stats.json and look for modules with issuerPath entries that trace back to a CommonJS dependency. Alternatively, rollup-plugin-visualizer colors CommonJS modules distinctly.
Corrective action: Migrate or wrap the CJS dependency. The techniques for converting CJS libraries to ESM — dual-package exports and the @rollup/plugin-commonjs transformMixedEsModules option — restore static analysis for these modules.
Missing sideEffects: false in package.json
Root cause: Without a sideEffects declaration, Webpack conservatively assumes every module in the package may mutate global state, and retains all exports even when none are imported.
Diagnostic signal: Build with WEBPACK_BUNDLE_ANALYZER=1 npm run build and inspect packages that appear in the bundle despite having no visible imports in your application code.
Corrective action: Add "sideEffects": false to the package’s package.json, or for packages with genuine side effects (polyfills, CSS imports), list only those files: "sideEffects": ["./src/polyfills.js", "*.css"]. The in-depth audit workflow is covered under configuring sideEffects for optimal tree-shaking.
pure_funcs stripping framework runtime helpers
Root cause: Configuring pure_funcs too broadly — for example, including function names that happen to match internal Vue or React helper names — causes Terser to remove calls that are actually live.
Diagnostic signal: Runtime errors like TypeError: __vue_component__ is not a function after a production build that works in development.
Corrective action: Restrict pure_funcs to application-level helpers (console.log, debug, your own logger.info) rather than short generic names. Scope hoisting via concatenateModules: true gives Terser better rename visibility, reducing false-positive matches.
Barrel files neutralize export-level pruning
Root cause: An index.ts that re-exports everything (export * from './components') forces Webpack and Rollup to treat every export as potentially used, because the static import graph cannot be narrowed below the barrel boundary.
Diagnostic signal: usedExports: true logs show /* unused harmony export */ absent from exports you expect to be pruned, but those exports are still present in the final bundle.
Corrective action: Replace barrel imports with direct module imports (import { Button } from './components/Button'). The full refactoring strategy is in refactoring barrel files to reduce bundle bloat.
Verification Workflow
1. Webpack: inspect the intermediate output
Build with webpack --mode=production --devtool=false and inspect the output JS. Search for /* unused harmony export */ comments — their presence confirms tree-shaking is annotating dead exports, and their absence in the final minified output confirms Terser removed them.
2. Rollup/Vite: bundle visualization
# Install rollup-plugin-visualizer and add it to vite.config.ts
npm install -D rollup-plugin-visualizer// vite.config.ts — Vite 5+
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({ open: true, gzipSize: true, brotliSize: true })
]
});The generated stats.html treemap shows each module’s contribution to the final bundle. Modules that should have been pruned but still appear are the first diagnostic target.
3. CI dead-code ratio gate
# .github/workflows/bundle-check.yml
- name: Build and validate dead-code ratio
run: |
npm run build -- --json=dist/stats.json
node scripts/validate-dead-code.js// scripts/validate-dead-code.js
// Webpack 5 — reads stats.json generated by --json flag
const fs = require('fs');
const stats = JSON.parse(fs.readFileSync('dist/stats.json', 'utf-8'));
const totalSize = stats.modules.reduce((acc, m) => acc + m.size, 0);
const deadSize = stats.modules
.filter(m => m.orphaned || m.reasons.length === 0)
.reduce((acc, m) => acc + m.size, 0);
const ratio = (deadSize / totalSize) * 100;
if (ratio > 4) {
console.error(`Dead code ratio ${ratio.toFixed(2)}% exceeds 4% threshold.`);
process.exit(1);
}
console.log(`Dead code ratio: ${ratio.toFixed(2)}% — within budget.`);4. DevTools verification
Open the Network tab, filter by JS, and check the transfer size of your main chunk against the uncompressed size. A healthy ratio is roughly 3:1 (compressed:uncompressed) for well-tree-shaken JavaScript. A ratio below 2.5:1 often signals that large blocks of highly repetitive dead code (such as duplicated polyfills) are still present.
Enforced performance budgets
| Metric | Threshold | Enforcement tool |
|---|---|---|
| Total JS transfer (brotli) | < 170 KB | size-limit CLI with --ci |
| Max single chunk size | < 50 KB | Webpack performance.maxAssetSize / Vite build.chunkSizeWarningLimit |
| Dead code ratio | < 4% | Custom stats validation script (above) |
| Build time | < 45 s | CI pipeline timeout + webpack --profile |
Legacy Dependency Migration
CommonJS modules remain the most common DCE blocker in production codebases. require() calls, module.exports[key] dynamic assignments, and top-level global mutations all prevent static analysis. The transitional configuration below bridges the gap while migration proceeds:
// webpack.config.js — Webpack 5 transitional CJS interop
module.exports = {
resolve: {
mainFields: ['module', 'main'], // Prefer ESM entry points
conditionNames: ['import', 'require'] // Resolve ESM condition first
}
};// vite.config.ts — Vite 5+ transitional CJS interop
import { defineConfig } from 'vite';
import commonjs from '@rollup/plugin-commonjs';
export default defineConfig({
plugins: [
commonjs({
transformMixedEsModules: true // Convert mixed CJS/ESM modules at transform time
})
]
});Once all legacy dependencies are converted to ESM (see the full workflow at converting CJS libraries to ESM for better bundling), remove these plugins to eliminate the AST transformation overhead they introduce.
FAQ
Why does usedExports: true not remove dead code from my npm packages?
npm packages must ship ESM (not CommonJS) and declare "sideEffects": false in their package.json for usedExports to prune individual exports from them. CommonJS require() calls are evaluated at runtime and are entirely opaque to static analysis — the bundler must retain the full module.
Does Vite perform dead code elimination in development mode?
No. Vite’s dev server uses native ES module loading without bundling, so DCE only runs during production builds where Rollup performs full tree-shaking and esbuild handles minification. Development and production bundles can differ significantly in size for this reason.
What is the difference between tree-shaking and dead code elimination?
Tree-shaking is the module-graph traversal pass that marks exports as used or unused. DCE is the subsequent AST transformation that physically removes the unused code branches. Modern bundlers chain both: tree-shaking marks, then the minifier eliminates. Misconfigurations in either step produce a different failure mode — tree-shaking failures leave /* unused harmony export */ annotations in place; DCE failures leave annotated-but-not-removed dead branches in the output.
Can I tree-shake CSS imported inside JavaScript modules?
CSS imported via import './styles.css' is treated as having side effects by default (it mutates the document’s style sheets). Mark only pure utility CSS as side-effect-free. For CSS Modules or utility-class frameworks, configure the sideEffects field in package.json to list CSS files separately: "sideEffects": ["**/*.css"].
Related
- Advanced Tree-Shaking & Dependency Optimization — parent strategy covering the full dependency optimization pipeline
- Configuring sideEffects for Optimal Tree-Shaking — the upstream annotation step that determines what DCE can safely remove
- Refactoring Barrel Files to Reduce Bundle Bloat — how barrel re-exports block export-level pruning and how to eliminate them
- Converting CJS Libraries to ESM for Better Bundling — migrating CommonJS dependencies that block the static analysis pass