Code Splitting, Tree Shaking & Bundle Optimization

The Barrel File Problem

A barrel file is an index.js or index.ts that consolidates and re-exports multiple exports from other files into a single entry point — export * from './Button'; export * from './Input'; export * from './Modal'. While convenient for import ergonomics, barrel files are one of the most significant performance anti-patterns in JavaScript. When you import a single component from a barrel file, the entire module graph behind it gets pulled in — every file the barrel re-exports, and all of their dependencies.

Pascal Schilp’s case study on MSW (Mock Service Worker) illustrates this vividly: importing just the http handler from MSW’s barrel file loaded 179 modules in the browser, including the entire GraphQL parser and its dependencies — none of which were used. By creating separate entrypoints (msw/http, msw/browser) and eliminating the barrel re-exports, the module count dropped to 45 modules — a 75% reduction that improved local dev page load by 67%. Marvin Hagemeister (Preact core team) has documented the broader impact: barrel files cause bundlers to slow down, export * and import * as patterns often can’t be tree-shaken correctly, and in testing environments where each test file spawns a new process, the cost of constructing the module graph is paid per file, causing lint and test times to spiral into hours on larger projects.

The damage extends beyond bundling. During Vite’s native ESM dev server mode, every module is a separate HTTP request — a barrel file that transitively imports 179 modules means 179 network requests on page load. Jason Miller’s ViteConf 2024 talk on scaling Vite at Shopify identified barrel files as a primary bottleneck. The fix is straightforward: import directly from the source module (import { Button } from './design-system/atoms/button') rather than through the barrel (import { Button } from './design-system'). For large codebases, Matteo Mazzarolo’s jscodeshift-based codemod automates this refactoring, and tools like barrel-begone can analyze your package’s entrypoints to identify barrel file problems. ESLint plugins like eslint-plugin-no-barrel-files and Oxlint rules can prevent new barrel files from being introduced.

Resources:

Tree Shaking and the sideEffects Flag

Tree shaking — the removal of unused code from bundles — is the single most important optimization for reducing JavaScript payload. It works by analyzing the static structure of ES module import and export statements to determine which exports are actually used, then eliminating the rest. The term was popularized by Rollup, and the concept now works across all modern bundlers (Rollup, Vite/Rolldown, webpack, Rspack, esbuild).

However, tree shaking has a critical limitation: side effects. A side effect is code that runs simply by virtue of importing a module — polyfills that modify globals, CSS imports, or modules that execute initialization logic at the top level. Bundlers cannot safely remove modules with potential side effects, because removing them might break the application. By default, bundlers like webpack err on the side of caution and assume every module might have side effects. This is why the "sideEffects" field in package.json is so important.

Setting "sideEffects": false tells the bundler that no file in the package has side effects, making it safe to skip entire modules (and their dependency subtrees) if their exports go unused. This is dramatically more effective than usedExports alone (which relies on Terser to detect side effects in individual statements). For packages with some side-effectful files (like CSS imports or polyfills), you can provide an array: "sideEffects": ["*.css", "./src/polyfills.js"]. The /*#__PURE__*/ inline annotation serves as an escape hatch for individual function calls, telling the bundler a specific expression is safe to remove if its result goes unused — this is particularly important for React Higher Order Components and other patterns that look like side effects to the compiler.

For library authors: always declare "sideEffects" in your package.json. Many popular packages (including, at various times, diff, react-dates, and others) have shipped without this field, making it impossible for consumers to tree-shake them effectively. If your library is a barrel file without sideEffects: false, webpack will include everything — the entire re-exported module graph — even if only one function is imported.

Resources:

Finding and Removing Dead Code with Knip

Even with tree shaking, your source code likely contains unused files, unused exports, and unnecessary dependencies that add complexity and slow down tooling. Knip is a project-level linter that fills the gap between file-level linters (ESLint finds unused variables within a file) and whole-project analysis (Knip finds exports that are never imported anywhere). It uses a mark-and-sweep algorithm: starting from entry points, it builds a complete dependency graph and identifies everything that isn’t reachable.

Knip detects unused files, unused exports (including class and enum members), unused dependencies and devDependencies, missing dependencies, and duplicate exports. It supports monorepos with workspaces and ships with 100+ plugins for frameworks and tools (Next.js, Astro, Vite, Jest, Storybook, and many more). The --fix flag can automatically remove unused exports from source files and unused dependencies from package.json. One developer ran Knip on a production React Native app and found 73 unused files, 74 unused exports, and 5 unnecessary dependencies — potentially thousands of lines of dead code. Storybook uses Knip in its own build pipeline.

For bundle-level analysis, use webpack-bundle-analyzer, Vite’s rollup-plugin-visualizer, or Lighthouse’s built-in treemap view (View Treemap in the Performance section) to inspect what’s actually in your production bundle and find opportunities for splitting or replacing heavy dependencies. Bundlephobia provides instant cost analysis for npm packages (download size, gzipped size, download time on slow networks) before you add them to your project.

Resources:

Code Splitting Strategies

Code splitting breaks your application into smaller chunks loaded on demand, ensuring users only download the code they need for the current view. Modern bundlers (Vite/Rolldown, webpack, Rspack) support several splitting strategies:

Route-based splitting is the highest-impact and easiest to implement. Use dynamic import() for route components: in React, combine React.lazy() with <Suspense> boundaries; in Next.js, the App Router automatically code-splits at the page/layout level; in React Router v7, use the lazy route property. This ensures each route loads only its own code, and Suspense shows a fallback while it loads.

Component-based splitting uses dynamic imports for heavy components that aren’t needed on initial render — modals, rich text editors, charts, date pickers. The pattern import('./HeavyComponent') tells the bundler to create a separate chunk. Pair with React.lazy() or a custom loading wrapper to defer these until interaction or visibility.

Package-level splitting separates vendor dependencies that change infrequently from application code that changes often. Configure your bundler’s chunk splitting (optimization.splitChunks in webpack/Rspack, manualChunks in Rollup/Rolldown) to create separate chunks for large dependencies like React, lodash, or chart libraries. These vendor chunks can be cached long-term by the browser via content-hashed filenames and Cache-Control: immutable headers.

Granular chunking takes this further by creating many small, shared chunks rather than a few large ones. Rolldown in Vite 8 provides more flexible chunk split control than the prior Rollup setup. The goal is to find the sweet spot — around 6–10 chunks is often a good compromise that balances HTTP/2 multiplexing benefits with the overhead of too many small requests. The HTTP Archive shows that most sites still over-bundle rather than under-split.

Resources:

Trimming Dependencies and Auditing Bundles

Regularly audit your dependency tree. There is a high chance you’re shipping full libraries when you only need a fraction. Lodash is the canonical example: importing import _ from 'lodash' pulls in the entire library (~70KB minified), while import debounce from 'lodash/debounce' pulls only ~1KB. Better yet, many lodash utilities now have native JavaScript equivalents or can be replaced with lightweight alternatives. Use babel-plugin-lodash or lodash-es (which supports tree shaking natively) to automate cherry-picking.

Beyond individual packages, assess whether you need your framework’s full weight on every page. Not every page of a SPA needs the complete framework bundle. Server Components in React 19 eliminate JavaScript for components that don’t need interactivity. Astro’s Islands architecture ships zero JavaScript by default, hydrating only the interactive “islands.” Consider whether a lighter alternative exists: Preact (3KB) instead of React for simpler use cases; date-fns instead of Moment.js (which is essentially unmaintained); or native fetch instead of Axios for simple HTTP requests.

For continuous monitoring, integrate bundle analysis into your CI workflow. Size-limit (covered in Section 3) checks both transfer size and execution time. Knip catches dead dependencies before they bloat your install. Lighthouse CI flags JavaScript bundles that exceed your performance budget. Together, these tools create a defense-in-depth approach that prevents bundle regression over time.

Resources: