Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Web Development

Frontend Bundle Optimization: Eliminating Dead Code and Side Effects

How to audit dynamic imports, parse dependency graphs, and configure bundlers to prune dead code and side-effect modules for faster page hydration.

Dian Rijal Asyrof/August 1, 2026/6 min read
Illustration for Frontend Bundle Optimization: Eliminating Dead Code and Side Effects

You run a build, deploy to production, and watch your Lighthouse scores drop. You check the network tab and see a massive bundle of JavaScript downloading, parsing, and executing. The frustrating part is that your users only interact with a fraction of that code during the initial page load. The rest is dead weight, dragging down your Interaction to Next Paint (INP) and delaying page hydration.

We often assume modern bundlers handle this automatically. We write clean ES Modules, trust tree-shaking, and expect the compiler to drop whatever we do not use. But tree-shaking is fragile. A single misplaced import or an undeclared side effect can trick your bundler into keeping thousands of lines of dead code.

To fix this, you need to understand how bundlers analyze your code, where they default to safety, and how to configure them to prune unused modules.

Mapping the Dependency Graph

To fix a bloated bundle, you first need to see what is inside it. Bundlers build a dependency graph by starting from your entry points (like main.js or index.tsx) and following every import statement.

If you use Webpack, webpack-bundle-analyzer is the standard tool. For Vite or Rollup, rollup-plugin-visualizer does the same job.

Here is how to set up the visualizer in a Vite project:

// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
 
export default defineConfig({
  plugins: [
    visualizer({
      filename: 'stats.html',
      open: true,
      gzipSize: true,
    }),
  ],
});

When you run your production build, this generates an interactive HTML file showing the size of every module.

Look for large blocks that seem out of place. If a utility library like Lodash or a heavy charting library takes up half the screen, but you only use one or two helper functions, your tree-shaking is broken. The bundler has pulled in the entire module because it could not verify that the unused parts were safe to discard.

The Problem with Side Effects

Why does tree-shaking fail? The primary culprit is side effects.

A module has a side effect if it executes code immediately upon import, rather than just exporting functions or variables. This includes modifying global variables, adding prototypes to built-in objects, or injecting style tags into the document head.

Consider this example:

// analytics.js
window.globalTracker = () => {
  console.log('Tracker initialized');
};
 
export const logEvent = (event) => {
  console.log(`Event: {event}`);
};

Even if you only import logEvent in your main application, the bundler cannot discard the rest of this file. If it removed the window.globalTracker assignment, the global tracker would never initialize, which might break other parts of your app.

Because JavaScript is highly dynamic, compilers err on the side of safety. If a bundler cannot guarantee that a module is free of side effects, it keeps the entire file in the final bundle.

Declaring Pure Modules with sideEffects

You can tell your bundler which modules are safe to prune by using the sideEffects property in your project's package.json.

If your code is entirely pure (no global mutations, no CSS imports outside components), set it to false:

{
  "name": "my-app",
  "version": "1.0.0",
  "sideEffects": false
}

If you have specific files that contain side effects, like CSS stylesheets or global polyfills, you must list them:

{
  "name": "my-app",
  "version": "1.0.0",
  "sideEffects": [
    "src/styles/global.css",
    "src/polyfills.js"
  ]
}

This tells the compiler: "If you import something from a file not on this list, and I do not use the exported values, you can safely delete the whole file."

This flag is critical for library authors. If you publish a component library without setting "sideEffects": false in its package.json, anyone using your library will end up importing the entire bundle, even if they only use a single button component.

The Cost of Barrel Files

Barrel files are files that re-export modules from other files. They look like this:

// components/index.js
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
export { Tooltip } from './Tooltip';

They make imports look clean:

import { Button } from '../components';

But barrel files are a major obstacle for tree-shaking. When you import Button from the barrel file, the engine has to evaluate the barrel file itself. If the barrel file imports Modal and Tooltip, and those components import massive third-party libraries, the bundler might pull in those libraries too.

To avoid this, import directly from the source file:

import { Button } from '../components/Button';

If you must use barrel files, ensure every sub-module is marked as side-effect free. In Webpack, you can also use the sideEffects optimization flag to help the compiler trace through these files.

Auditing Dynamic Imports

Dynamic imports (import()) are the primary way we split our code into smaller chunks. Instead of loading everything upfront, we load chunks on demand.

const loadCharts = () => import('./charts.js');

But dynamic imports introduce new complications. Every dynamic import creates a new entry point in the dependency graph. The bundler has to split the code and create a separate chunk.

If charts.js imports a library that is also used in your main bundle, the bundler has to decide where to put that library. Does it duplicate it in both chunks? Or does it create a third, shared chunk?

If you have too many dynamic imports, you end up with dozens of tiny files. This causes network waterfall issues. A user requests a page, which loads a chunk, which dynamically imports another chunk, which imports another. The browser spends more time waiting for network requests than executing code.

To fix this, group related dynamic imports or use bundler hints to preload critical chunks.

In Webpack, you can use magic comments to group chunks:

const HeavyComponent = () => import(
  /* webpackChunkName: "heavy-components" */
  /* webpackPrefetch: true */
  './HeavyComponent'
);

In Vite, you can configure manual chunks to control how code is grouped:

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('lodash') || id.includes('ramda')) {
              return 'utility-vendor';
            }
          }
        }
      }
    }
  }
};

CommonJS vs. ES Modules

Sometimes the dead code is not yours. It lives inside your dependencies.

Many older libraries only distribute CommonJS (module.exports and require) modules instead of ES Modules (import and export). Tree-shaking relies on static analysis. Because CommonJS imports are dynamic (you can require a file inside an if statement), bundlers cannot safely analyze them at build time. They have to include the entire module.

If you are using Lodash, swap it for lodash-es, which uses ES Modules. If a library does not offer an ESM build, look for alternative packages or use plugins to transform them during the build process.

For example, in a Vite project, you can use @rollup/plugin-commonjs to convert CommonJS modules to ES6, making them eligible for tree-shaking.

Configuring Bundlers for Maximum Pruning

Let's look at concrete configurations to enforce dead code elimination.

Vite / Rollup Configuration

Vite uses Rollup under the hood for production builds. Rollup is excellent at tree-shaking, but you can fine-tune its behavior.

// vite.config.js
export default {
  build: {
    minify: 'esbuild',
    rollupOptions: {
      treeshake: {
        preset: 'recommended',
        moduleSideEffects: 'no-external', // Assume external packages have no side effects unless declared
      },
    },
  },
};

Webpack Configuration

If you are using Webpack, you need to be more explicit about optimization settings in your configuration file.

// webpack.config.js
module.exports = {
  mode: 'production', // Automatically enables optimization.usedExports and optimization.sideEffects
  optimization: {
    usedExports: true, // Determine used exports for each module
    sideEffects: true, // Recognize the sideEffects flag in package.json
    innerGraph: true, // Track variables to find unused exports deeper in the code
  },
};

The Hydration Cost

Why does this matter so much for modern frameworks like React, Vue, or Solid? It comes down to hydration.

Hydration is the process where client-side JavaScript attaches event listeners to the server-rendered HTML. Before hydration completes, the page might look interactive, but clicking buttons does nothing.

If your bundle contains unused code, the browser still has to download it, parse it, and compile it. The main thread is blocked during this parsing phase. If a user tries to click a link while the browser is busy parsing 500KB of unused utility functions, they experience lag. This directly hurts your Interaction to Next Paint (INP) score.

By pruning dead code, you reduce the CPU work required before the page becomes interactive.

Audit Checklist for Teams

To keep your bundle clean, make auditing part of your workflow:

  1. Run a bundle analysis on every major release or pull request to spot unexpected size increases.
  2. Set up budget limits using tools like size-limit to fail builds if the bundle grows past a set threshold.
  3. Check your package.json for the sideEffects flag. If you are writing a shared internal library, this is mandatory.
  4. Prefer direct imports over barrel files for large component directories.
  5. Replace CommonJS dependencies with ES Module alternatives wherever possible.
DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleEthereum's Glamsterdam Hard Fork Enters Final Testing PhaseNext articleSolana Changelog: Native Subscription Cancellations and Associated Token Account Helpers
Web DevelopmentBest PracticesFrontend
On this page↓
  1. Mapping the Dependency Graph
  2. The Problem with Side Effects
  3. Declaring Pure Modules with sideEffects
  4. The Cost of Barrel Files
  5. Auditing Dynamic Imports
  6. CommonJS vs. ES Modules
  7. Configuring Bundlers for Maximum Pruning
  8. Vite / Rollup Configuration
  9. Webpack Configuration
  10. The Hydration Cost
  11. Audit Checklist for Teams

On this page

  1. Mapping the Dependency Graph
  2. The Problem with Side Effects
  3. Declaring Pure Modules with sideEffects
  4. The Cost of Barrel Files
  5. Auditing Dynamic Imports
  6. CommonJS vs. ES Modules
  7. Configuring Bundlers for Maximum Pruning
  8. Vite / Rollup Configuration
  9. Webpack Configuration
  10. The Hydration Cost
  11. Audit Checklist for Teams

See also

Illustration for Optimizing Core Web Vitals in Next.js App Router: LCP, FID, and CLS Practical Fixes
Web Development/Jul 30, 2026

Optimizing Core Web Vitals in Next.js App Router: LCP, FID, and CLS Practical Fixes

A comprehensive guide on diagnosing and fixing Largest Contentful Paint, First Input Delay, and Cumulative Layout Shift issues specifically within the Next.js App Router paradigm.

8 min read
Next.jsReact
Illustration for Building Fault-Tolerant Background Job Queues Natively in Postgres
Software Engineering/Aug 1, 2026

Building Fault-Tolerant Background Job Queues Natively in Postgres

How to implement transactional, robust background job queues in PostgreSQL using SELECT FOR UPDATE SKIP LOCKED without adding external cache dependencies.

6 min read
Software EngineeringBest Practices
Illustration for Designing API Idempotency Keys for Distributed Payments
Software Engineering/Jul 31, 2026

Designing API Idempotency Keys for Distributed Payments

A guide to implementing safe API idempotency keys in distributed system endpoints using transactional locks, storage expiry, and unique request identifiers.

7 min read
Software EngineeringBest Practices