Bundle Size Optimization: A Practical Frontend Guide
A hands-on guide to measuring and reducing JavaScript bundle size with tree shaking, code splitting, dependency swaps, compression, and CI budgets.
A bloated JavaScript bundle is one of the fastest ways to hurt Time to Interactive and your Core Web Vitals score. The good news is that you can usually shrink it a lot by measuring first, then picking the right combination of tree shaking, code splitting, dependency swaps, compression, and CI budgets. This guide covers webpack, Vite, and Rollup.
When to Use
- Initial page load feels slow and Lighthouse flags large JavaScript payloads.
- A dependency audit reveals oversized or duplicate libraries.
- You’ve got routes or below-the-fold components that can load later.
- You want to enforce bundle budgets in CI so regressions fail the build.
When NOT to Use
- The bundle is already small and the real bottleneck is the network or server response time.
- You’re optimizing before measuring. Use a profiler or analyzer first.
- Server-side rendering is the main delivery path and HTML weight matters more than JS bundle size.
Analyzing Bundle Size
Before changing anything, you need a clear picture of what is inside the bundle.
Webpack Bundle Analyzer
npm install -D webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: "static",
openAnalyzer: false,
reportFilename: "bundle-report.html",
}),
],
};
Vite Bundle Visualizer
npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from "rollup-plugin-visualizer";
export default {
plugins: [
visualizer({
filename: "bundle-stats.html",
gzipSize: true,
brotliSize: true,
}),
],
};
Source Map Explorer
npm install -D source-map-explorer
npx source-map-explorer dist/*.js
Tree Shaking
Tree shaking removes unused exports, but it only works with ES modules and production builds.
// Bad: imports all of lodash
import _ from "lodash";
const result = _.chunk([1, 2, 3, 4], 2);
// Good: import only the function
import { chunk } from "lodash-es";
const result = chunk([1, 2, 3, 4], 2);
Webpack
// webpack.config.js
module.exports = {
mode: "production",
optimization: {
usedExports: true,
sideEffects: false,
},
};
// package.json
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}
Vite
// vite.config.js
export default {
build: {
rollupOptions: {
treeshake: true,
},
},
};
Code Splitting
Route-based splitting in React
import { lazy, Suspense } from "react";
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
);
}
Component-based splitting
import { lazy, Suspense, useState } from "react";
const Chart = lazy(() => import("./components/Chart"));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show chart</button>
{showChart && (
<Suspense fallback={<div className="chart-skeleton" />}>
<Chart data={chartData} />
</Suspense>
)}
</div>
);
}
Webpack splitChunks
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
minSize: 20000,
maxSize: 244000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all",
priority: 10,
},
},
},
},
};
Dynamic Imports
// Load module on demand
const module = await import("./heavy-module.js");
module.doSomething();
// Prefetch on hover
button.addEventListener("mouseenter", () => {
import(/* webpackPrefetch: true */ "./Chart");
}, { once: true });
// Preload critical chunk in parallel
import(/* webpackPreload: true */ "./CriticalChart");
Dependency Replacement
Common swaps that reduce bundle size:
| From | To | Savings |
|---|---|---|
| moment.js (293KB) | date-fns (13KB) or dayjs (2KB) | large |
| lodash | lodash-es or native methods | large |
| axios | native fetch | 13KB |
| uuid | crypto.randomUUID() | 7KB |
// Replace lodash with native array methods
const result = array
.map((x) => x * 2)
.filter((x) => x > 10)
.reduce((sum, x) => sum + x, 0);
// Replace uuid with native crypto
const id = crypto.randomUUID();
// Replace axios with fetch
const res = await fetch("/api/users");
const data = await res.json();
Check sizes on bundlephobia.com before installing a new package.
Compression
Build-time compression
// Vite with vite-plugin-compression2
import { compression } from "vite-plugin-compression2";
export default {
plugins: [
compression({ algorithm: "gzip", threshold: 10240 }),
compression({ algorithm: "brotliCompress", threshold: 10240 }),
],
};
Nginx static pre-compressed files
server {
gzip_static on;
brotli_static on;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Serve brotli first; gzip is the fallback for older browsers.
Polyfill Management
// Bad: import every polyfill
import "core-js/stable";
// Good: import only what you need
import "core-js/stable/promise";
import "core-js/stable/array/flat";
// Better: let Babel inject usage-based polyfills
// babel.config.js
module.exports = {
presets: [
["@babel/preset-env", { useBuiltIns: "usage", corejs: 3 }],
],
};
Module Federation
Use module federation to share dependencies across micro-frontends at runtime.
// webpack.config.js: host
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
remoteApp: "remoteApp@https://cdn.example.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
},
}),
],
};
// Host app
import { lazy, Suspense } from "react";
const RemoteWidget = lazy(() => import("remoteApp/Widget"));
function App() {
return (
<Suspense fallback={<div>Loading widget...</div>}>
<RemoteWidget />
</Suspense>
);
}
Bundle Monitoring
Size budgets in webpack
// webpack.config.js
module.exports = {
performance: {
hints: "warning",
maxAssetSize: 244000,
maxEntrypointSize: 244000,
assetFilter: (filename) => !filename.endsWith(".map"),
},
};
CI size check with bundlesize
// package.json
{
"scripts": {
"size-check": "bundlesize"
},
"bundlesize": [
{ "path": "dist/assets/*.js", "maxSize": "100KB" },
{ "path": "dist/assets/*.css", "maxSize": "20KB" }
]
}
Best Practices
- Measure first with a bundle analyzer. Guessing wastes time.
- Prefer native APIs over libraries when the browser already supports what you need.
- Split by route first, then by heavy below-the-fold components.
- Review dependency diffs before bumping a package version.
- Set bundle budgets in CI and fail builds that exceed them.
- Compress assets with both gzip and brotli, and serve
.brwhen possible.
Common Mistakes
- Guessing the bottleneck: always analyze before changing dependencies.
- Importing full libraries:
import _ from "lodash"brings the entire package. - Splitting too granularly: hundreds of tiny chunks hurt caching and add HTTP overhead.
- Ignoring compression: serving uncompressed JS wastes bandwidth.
- Forgetting polyfill scope: global polyfills bloat modern browsers.
- Over-engineering module federation: it adds complexity that small teams rarely need.
Frequently Asked Questions
What is tree shaking and how does it work?
Tree shaking removes unused exports from ES modules. It needs import/export syntax, a production
build, and packages marked as side-effect free. Webpack requires mode: "production"; Vite and
Rollup do it by default.
How is code splitting different from tree shaking?
Tree shaking removes dead code. Code splitting breaks the bundle into smaller chunks loaded on demand. Use both: tree shake first, then split by route or heavy component.
What is the difference between prefetch and preload?
Prefetch loads a resource during idle time for likely future use. Preload loads it immediately in
parallel with the current page. Use webpackPrefetch for next routes and webpackPreload for
critical current-page assets.
How do I know which dependencies are bloating my bundle?
Use webpack-bundle-analyzer, rollup-plugin-visualizer, or source-map-explorer. Check sizes on
bundlephobia.com before installing. Run npm ls to spot duplicate dependencies.
Should I use gzip or brotli compression?
Use both. Brotli compresses text 15-25% better. Serve .br first and .gz as fallback. Most CDNs
and static hosts support brotli.
How do module federation and micro-frontends affect bundle size?
Module federation lets several apps share dependencies at runtime, reducing duplicate code. It adds runtime overhead for loading remote entries. Use it for large teams with independent deployments, not small apps.
Related Resources
Complete Guide to Web Performance and Core Web Vitals
Optimize Core Web Vitals. Covers LCP, INP, CLS measurement and improvement, image optimization, font loading, render-blocking resources, lazy loading, caching strategies, and performance monitoring with practical code examples.
GuideComplete Guide to React 19 Features
Master React 19 features. Covers server components, use() hook, actions, form actions, useActionState, useOptimistic, useFormStatus, ref as prop, document metadata, asset loading, and React Compiler with practical code examples.
GuideComplete Guide to CSS Grid and Flexbox
Master modern CSS layout with Grid and Flexbox. Covers grid templates, areas, subgrid, responsive layouts, flexbox alignment, wrapping, gap, container queries, and when to use Grid vs Flexbox with practical examples and patterns.
RecipeDebounce and Throttle Functions in JavaScript
Control function execution rate with debounce and throttle. Covers leading and trailing edge, cancelable timers, and real-world use cases.
RecipeJavaScript Event Loop
Understand how the JavaScript event loop works internally and how to write non-blocking code.
RecipeWeb Performance Optimization
Improve Core Web Vitals, reduce bundle sizes, and optimize frontend performance with lazy loading, code splitting, and modern build tools.