JavaScript Bundle Size Reduction: Code Splitting and Tree Shaking in Practice
Large JavaScript bundles are a primary cause of slow page loads and poor INP scores. Here's a practical guide to code splitting, tree shaking, and measuring what actually ships.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
JavaScript Is the Most Expensive Resource Type
Bytes of JavaScript cost more than bytes of any other resource type. An image is just decoded and displayed. JavaScript must be downloaded, parsed, compiled, and executed — and during execution, it blocks the main thread, preventing user interaction. This is why JavaScript is the primary driver of poor INP scores and sluggish page interactivity, even on fast networks.
Modern JavaScript tooling (Vite, webpack, esbuild, Rollup) has made it easier than ever to ship optimized bundles, but the tools only do what you tell them to. Understanding code splitting and tree shaking well enough to configure them correctly — and to catch when they're not working — is the practical skill this guide covers.
Tree Shaking: Eliminating Dead Code
Tree shaking is the process of excluding exported functions, classes, and variables that are never imported anywhere in your application. The term comes from the image of shaking a dependency tree to make dead leaves fall off.
How it works: Modern bundlers analyze the static import graph of your application and determine which exports are actually used. Exports that are never imported are excluded from the output bundle.
The prerequisite — ES modules. Tree shaking only works with ES module syntax (import/export). CommonJS modules (require/module.exports) are not statically analyzable, so bundlers can't determine which exports are used and must include everything. When you install a library, check whether it provides an ES module build — most modern libraries do (look for "module" in package.json, or .esm.js or .mjs variants).
The most common tree shaking failure: Barrel imports.
// This imports the entire library
import { something } from 'lodash'
// Even with tree shaking, this might pull in much more than `debounce`
import { debounce } from 'lodash'
// This definitely imports only debounce
import debounce from 'lodash-es/debounce'
The lodash library is CommonJS and not tree-shakeable. lodash-es is the ES module version. This one change can eliminate hundreds of kilobytes from a bundle.
Verify tree shaking is working: Use rollup-plugin-visualizer or webpack's webpack-bundle-analyzer to generate a visual map of your bundle content. If you see large libraries that you thought you were using selectively, the tree shaking isn't working for those modules.
Code Splitting: Loading What's Needed
Code splitting divides your application's JavaScript into multiple chunks that can be loaded on demand rather than all at once. The browser loads only what's needed for the current page, and defers loading the rest until it's needed.
Route-based splitting is the most important form. A multi-page application that loads JavaScript for all pages on initial load is wasting bandwidth for most users. With route-based splitting, each route gets its own chunk.
In Vite (and React with lazy loading):
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const ReportsPage = lazy(() => import('./pages/Reports'))
Each import() creates a separate chunk. The dashboard chunk loads when the user navigates to /dashboard, not on initial page load.
In Nuxt.js, this happens automatically — each file in pages/ is a separate chunk by default.
Component-level splitting for large components that aren't visible in the initial viewport:
// Load the chart library only when the chart component is rendered
const HeavyChart = lazy(() => import('./components/HeavyChart'))
Chart libraries (Chart.js, Recharts, Victory) are typically 200-400KB. If charts only appear after user interaction, lazy loading them can dramatically reduce initial load size.
Prefetching for routes the user is likely to navigate to:
<link rel="prefetch" href="/chunks/dashboard.js">
Or in React Router, prefetch on hover so the chunk is available by the time the user clicks.
Analyzing What's In Your Bundle
You can't optimize what you can't see. Generate a bundle visualization before and after optimization work.
Vite:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({ open: true, gzipSize: true })
]
}
After running vite build, a treemap opens showing every module and its size. Large rectangles in unexpected places are your optimization opportunities.
Common findings in bundle analysis:
Moment.js: 230KB+ with all locale data. Solution: use date-fns or day.js instead (they're tree-shakeable and much smaller), or import only the locales you need.
Large UI component libraries imported wholesale: if you're using 3 components from a 150-component library and importing the whole thing, you're carrying 147 unused components. Switch to named imports and ensure the library is tree-shakeable, or switch to a smaller, more focused library.
Duplicate packages: different versions of the same package appearing multiple times in the bundle, usually because of transitive dependencies. Check with npm ls or pnpm dedupe.
Development-only code in production builds: sometimes process.env.NODE_ENV checks aren't being evaluated at build time, leaving development warnings and checks in the production bundle. Ensure your bundler is configured to replace process.env.NODE_ENV with the literal string 'production'.
Practical Targets
Initial page bundle: Under 100KB of JavaScript (gzipped) for the critical path. This is the JavaScript needed to make the page interactive. Route-based code splitting should ensure that most application code is not in this bundle.
Per-route chunk: Under 50KB (gzipped) for most route-specific code. Very complex routes (data grids, charts, complex forms) may reasonably exceed this.
Total JavaScript shipped: Track this metric over time in your CI pipeline. Bundle size regressions — where a dependency update or new feature suddenly adds 100KB — are much easier to catch and address if you see them immediately rather than discovering them six months later.
Measuring the Impact of Bundle Changes
Bundle size is a proxy metric. The real metrics are Time to Interactive (TTI) and INP. Measure these in the browser, not just in build output.
Chrome DevTools Performance tab: Load the page in an incognito window on a simulated slow 4G connection (Ctrl+Shift+P → Throttle). The Performance panel shows exactly when the main thread is blocked by JavaScript parsing and execution.
Lighthouse: The "Reduce JavaScript execution time" and "Remove unused JavaScript" diagnostics tell you which specific scripts are the largest contributors to execution time.
Web Vitals in production: Ship the web-vitals library and collect INP from real users on real devices and real networks. A bundle optimization that improves INP from 350ms to 180ms is a concrete, measurable win.
The Dependency Evaluation Habit
The most impactful long-term habit for managing bundle size is evaluating the weight of a dependency before adding it to the project.
Before npm install:
- Check bundlephobia.com for the package's gzipped size
- Check whether it has a tree-shakeable ES module build
- Check whether a lighter-weight alternative exists
Adding a 200KB library to solve a 10-line problem that could have been solved with a 10-line custom implementation is a decision that's hard to reverse after the library is woven into the codebase.
JavaScript bundle size is one of the most controllable performance variables — the tools exist, the techniques are learnable, and the impact is measurable. If you're working on a site with slow interactivity and want to diagnose and address the bundle size contributions, book a call at calendly.com/jamesrossjr.