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.
Introduction
A bloated JavaScript bundle is one of the fastest ways to hurt Time to Interactive and your Core Web Vitals score. I’ve seen teams ship 2MB JS bundles to mobile users and wonder why their LCP is 6 seconds. 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’s for frontend developers and tech leads who own performance budgets. You should be comfortable with npm, a bundler (webpack, Vite, or Rollup), and basic CI. If you’re new to performance metrics, read our Core Web Vitals guide first — this guide assumes you know what LCP, CLS, and INP measure.
We cover webpack, Vite, and Rollup. Most techniques work across all three; where they differ, I’ll show the config for each. For debounce and throttle patterns that complement bundle optimization, see our debounce and throttle recipe.
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, get a clear picture of what’s 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
I reach for source-map-explorer when bundle-analyzer shows a big chunk but I can’t tell which source files contributed to it. It reads the source map and shows a tree of what ended up where — useful for tracking down transitive dependencies that snuck in via a sub-import.
Tree Shaking
Tree shaking removes unused exports, but it only works with ES modules and production builds.
If you’re importing from CommonJS (require()), the bundler can’t statically analyze what you
use, so it includes everything. This is why lodash (CommonJS) ships 70KB even if you only use
chunk, while lodash-es (ESM) ships 1KB for the same function. Still wrapping code in IIFEs?
The Module Pattern explains the migration to ES modules — the step
that actually makes your own code tree-shakeable.
// 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
Code splitting breaks your bundle into smaller chunks that load on demand. The goal is to ship less JavaScript on the initial page load and fetch the rest when the user actually needs it.
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
Not everything splits by route. Heavy components — charts, editors, maps — are better split individually so they only load when the user actually interacts with them.
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");
I use prefetch sparingly — it’s easy to over-prefetch and end up loading everything. A good rule: prefetch only the next likely route (e.g., the dashboard when the user is on the login page) and preload only assets the current page will definitely need. If you prefetch five routes, you’re back to shipping the whole bundle, just spread across idle time.
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. I make this a rule on every PR review — no new dependency without a size check. It’s saved my team from pulling in 500KB libraries more than once.
Compression
Compression is the cheapest win in bundle optimization. Brotli and gzip reduce transfer size by 60-80% with zero code changes. The only question is whether to compress at build time or at the CDN — I prefer build time because it saves CPU on the edge and gives you explicit control.
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.
CDN-level compression
If you’re on Cloudflare, Fastly, or Vercel, the CDN can auto-compress responses. But pre-compressing at build time is better — it saves CPU on the edge and lets you control quality settings explicitly.
# vercel.json
{
"functions": {},
"routes": [
{
"src": "/assets/(.*).js",
"headers": {
"Content-Encoding": "br",
"Cache-Control": "public, max-age=31536000, immutable"
}
}
]
}
Set Cache-Control: immutable for fingerprinted assets (files with hashes in the name). The
browser never needs to revalidate them because the hash changes when the content does. I’ve seen
teams skip this and pay for it with repeated 304 requests on every navigation.
Polyfill Management
Polyfills are a silent bundle killer. If you import core-js/stable globally, you ship every
polyfill to every browser — including modern browsers that don’t need 90% of them. I’ve seen
this add 80KB to a bundle that should have been 40KB.
// 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 }],
],
};
The usage setting scans your code and injects only the polyfills for APIs you actually use,
targeted to the browsers in your .browserslistrc. Set your browser targets carefully — if you
support IE11, you’ll pull in a lot more polyfills than if you target last 2 versions of modern
browsers. For most projects in 2026, dropping IE11 support saves 40-60KB of polyfills alone.
Module Federation
Use module federation to share dependencies across micro-frontends at runtime. This is powerful for large teams with independent deployment cycles — each team ships their own remote entry, and shared dependencies like React are loaded once. But it adds runtime complexity: version negotiation, remote entry caching, and fallback handling. I’d only reach for it when you’ve got 3+ teams deploying independently and the coordination cost of a monorepo is too high.
// 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
Once you’ve optimized the bundle, you need to keep it small. Bundle budgets in CI catch regressions before they reach production. I set up budgets on every project — without them, dependencies creep in and the bundle grows 10-20% per quarter.
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" }
]
}
Image Optimization
Images often outweigh JavaScript in total page weight. I’ve seen landing pages with 4MB of unoptimized PNGs sitting next to a 200KB JS bundle — the team was optimizing the wrong thing.
Modern formats
<picture>
<source srcset="/img/hero.avif" type="image/avif" />
<source srcset="/img/hero.webp" type="image/webp" />
<img src="/img/hero.jpg" alt="Dashboard overview" width="1200" height="630" loading="lazy" />
</picture>
AVIF compresses 50% better than JPEG. WebP’s the fallback with 25-35% savings. Serve both with
<picture> and a JPEG fallback for ancient browsers.
Responsive images
<img
srcset="/img/hero-480.webp 480w, /img/hero-800.webp 800w, /img/hero-1200.webp 1200w"
sizes="(max-width: 600px) 480px, (max-width: 900px) 800px, 1200px"
src="/img/hero-800.webp"
alt="Dashboard"
loading="lazy"
decoding="async"
/>
Don’t ship a 1200px image to a 375px phone. srcset lets the browser pick the right size. I
learned this the hard way when Lighthouse flagged a 1.2MB hero image on mobile that was only
displayed at 375px wide.
Build-time image optimization
npm install -D vite-imagetools
// vite.config.js
import { imagetools } from "vite-imagetools";
export default {
plugins: [
imagetools({
defaultDirectives: (url) => ({
format: "webp",
quality: 80,
}),
}),
],
};
For webpack, use image-webpack-loader to auto-optimize images at build time. Set quality to 80
for photos and 90 for screenshots with text.
Font Loading
Fonts block text rendering if you don’t handle them carefully. A custom font that takes 2 seconds to load causes a flash of invisible text (FOIT) or a flash of unstyled text (FOUT).
font-display swap
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
font-display: swap shows fallback text immediately and swaps when the font loads. Use
optional for non-critical fonts — the browser’ll skip loading them entirely on slow
connections.
Preload critical fonts
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
Preload the one font file that matters for above-the-fold text. Don’t preload every weight — I once saw a site preload 12 font files, adding 1.8MB to the critical path.
Subset fonts
npx fonttools subsetting --input inter.ttf --output inter-subset.woff2 --unicodes "U+0020-007F"
Subset to the characters you actually use. A full Inter variable font is 800KB. Subset to Latin
and it drops to 60KB. For a Spanish site, include Latin Extended (U+00C0-00FF) for accented
characters. Use woff2 — it’s 30% smaller than woff.
CSS Optimization
CSS is smaller than JS, but unused CSS still blocks rendering. A 200KB CSS file with 80% unused selectors slows down every page load.
Purge unused CSS
// vite.config.js with Tailwind v4
import { tailwindcss } from "@tailwindcss/vite";
export default {
plugins: [tailwindcss()],
};
Tailwind v4 purges automatically based on your content files. For non-Tailwind projects, use PurgeCSS:
// postcss.config.js
const purgecss = require("@fullhuman/postcss-purgecss");
module.exports = {
plugins: [
purgecss({
content: ["./src/**/*.html", "./src/**/*.tsx"],
defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
}),
],
};
I’ve seen CSS bundles drop from 280KB to 12KB after purging. The catch: dynamic class names constructed at runtime get purged. Safelist them or use complete class names in your templates.
Critical CSS
Extract above-the-fold CSS and inline it in <head> to eliminate render-blocking:
<style>
/* Critical CSS: hero, nav, typography */
body { margin: 0; font-family: system-ui, sans-serif; }
.hero { min-height: 60vh; }
</style>
<link rel="stylesheet" href="/css/full.css" media="print" onload="this.media='all'"" />
Use critters for Astro/Next.js or critical for static HTML. The async CSS load trick
(media="print" + onload) defers the full stylesheet without blocking first paint.
Service Worker Caching
A service worker can cache assets locally, eliminating repeat downloads. This doesn’t reduce initial bundle size, but it dramatically improves repeat visit performance.
// sw.js
const CACHE = "app-v1";
const ASSETS = ["/", "/css/main.css", "/js/app.js"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request)),
);
});
Use Workbox for production-grade caching strategies:
// sw.js with Workbox
import { registerRoute } from "workbox-routing";
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({ cacheName: "images" }),
);
registerRoute(
({ request }) => request.destination === "script",
new StaleWhileRevalidate({ cacheName: "scripts" }),
);
Cache images with CacheFirst (they rarely change) and scripts with
StaleWhileRevalidate (serve cached, fetch updated in background). For more on event loop
behavior that affects when service workers run, see our
JavaScript event loop recipe.
Real-World Case Study: 2.4MB to 380KB
I worked on a React dashboard that shipped a 2.4MB JS bundle. Here’s what we did and the impact of each step:
| Step | Technique | Bundle size | Savings |
|---|---|---|---|
| 0 | Baseline | 2,400KB | — |
| 1 | Replace moment.js with dayjs | 2,120KB | -280KB |
| 2 | Replace lodash with lodash-es + tree shaking | 1,780KB | -340KB |
| 3 | Route-based code splitting (5 routes) | 1,100KB initial | -680KB initial |
| 4 | Component splitting for charts (Recharts) | 680KB initial | -420KB initial |
| 5 | Remove unused dependencies (npm prune) | 620KB | -60KB |
| 6 | Brotli compression | 380KB transferred | -240KB transferred |
The key insight: steps 1-2 were quick wins that took an afternoon. Step 3 was the biggest single win — most users only visited one route, so loading all five upfront was pure waste. Step 4 moved the charting library out of the initial bundle entirely. The charts only loaded when the user clicked the “Analytics” tab.
After these changes, LCP dropped from 4.2s to 1.1s on a mid-range Android phone with 3G. Total effort? About 2 days. The lesson: measure first, target the biggest items, and don’t over-engineer — we never needed module federation.
Best Practices
- Measure first with a bundle analyzer. I wasted a day swapping dependencies before realizing 60% of the bundle was a single charting library I didn’t need to load upfront.
- Prefer native APIs over libraries when the browser already supports what you need —
fetchbeatsaxios,crypto.randomUUID()beatsuuid. - Split by route first, then by heavy below-the-fold components.
- Review dependency diffs before bumping a package version — a minor update can double a package’s size.
- Set bundle budgets in CI and fail builds that exceed them. Without enforcement, budgets are just suggestions.
- Compress assets with both gzip and brotli, and serve
.brwhen possible.
Common Mistakes
- Guessing the bottleneck: I’ve done this — spent hours tree-shaking when the real issue was a 3MB image. Analyze first.
- Importing full libraries:
import _ from "lodash"pulls in the entire package. Uselodash-eswith named imports instead — it’s a one-line fix. - Splitting too granularly: hundreds of tiny chunks hurt caching and add HTTP overhead. I aim for 5-15 route-level chunks, not 200 component chunks.
- Ignoring compression: serving uncompressed JS wastes bandwidth. Brotli is free savings.
- Forgetting polyfill scope: global polyfills bloat modern browsers. Use usage-based injection.
- Over-engineering module federation: it adds complexity that small teams rarely need. I’ve seen teams adopt it for a 2-person project — it was overkill.
See Also
- webpack optimization guide — official webpack production build configuration.
- Vite build optimization — official Vite build options including rollupOptions and chunking.
- Rollup configuration — Rollup output and tree shaking options.
- bundlephobia — check the cost of any npm package before installing.
- Core Web Vitals guide — our complete guide to LCP, CLS, and INP optimization.
- Web performance recipe — practical performance techniques that complement bundle optimization.
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.
What tools identify 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.
When should I consider module federation for 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
A practical walkthrough of React 19: server components, the use() hook, actions, ref as a prop, document metadata, asset loading, and the React Compiler.
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.