JavaScript Bundle Size Optimization: Reduce Your App's Footprint
Complete guide to reducing JavaScript bundle size. Learn tree-shaking, code-splitting, dynamic imports, and compression techniques to build faster web applications with smaller payloads.
Introduction
JavaScript bundle size directly impacts your application's load time, and load time directly affects user experience and SEO rankings. According to HTTP Archive, the median JavaScript bundle size for websites is ~460 KB, but the top 10% of sites serve less than 100 KB.
Large bundles mean:
- ❌ Slower page loads (especially on mobile)
- ❌ Higher bandwidth costs
- ❌ Poor user experience
- ❌ Lower SEO rankings (Core Web Vitals)
- ❌ Reduced conversion rates
In this comprehensive guide, we'll:
- Analyze what makes bundles large
- Learn proven optimization techniques
- Implement tree-shaking and code-splitting
- Measure bundle size improvements
- Test everything using Perf Lens for accurate measurements
By the end of this article, you'll know how to build lean, fast-loading web applications.
Understanding Bundle Size
What's in Your Bundle?
A typical JavaScript bundle includes:
- 1. Your application code** (30-40%)
- 2. Third-party libraries** (40-50%)
- 3. Framework code** (React, Vue, Angular) (10-20%)
- 4. Polyfills and utilities** (5-10%)
Measuring Bundle Size
Development vs Production:
# Development bundle (unminified) main.js: 2.5 MB # Production bundle (minified) main.js: 450 KB (minified) main.js: 120 KB (minified + gzipped) main.js: 95 KB (minified + brotli)
Key Metrics:
- Raw size: Actual file size on disk
- Minified size: After removing whitespace and comments
- Gzipped size: After compression (70-80% smaller)
- Brotli size: Better compression (~20% smaller than gzip)
Optimization Technique #1: Tree-Shaking
What is Tree-Shaking?
Tree-shaking eliminates dead code by only including functions you actually use.
❌ Without Tree-Shaking:
// Imports entire lodash library (70 KB) import _ from 'lodash'; const result = _.debounce(myFunction, 300);
✅ With Tree-Shaking:
// Imports only debounce function (2 KB) import debounce from 'lodash/debounce'; const result = debounce(myFunction, 300);
Savings: 68 KB (97% reduction!)
How to Enable Tree-Shaking
1. Use ES6 Module Syntax:
// ✅ Good (tree-shakeable) export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; } // ❌ Bad (not tree-shakeable) module.exports = { add: (a, b) => a + b, subtract: (a, b) => a - b };
2. Configure Webpack:
// webpack.config.js module.exports = { mode: 'production', optimization: { usedExports: true, sideEffects: false } };
3. Mark Side Effects in package.json:
{ "name": "my-library", "sideEffects": false }
Or specify files with side effects:
{ "sideEffects": ["*.css", "*.scss"] }
Tree-Shaking Best Practices
Use Named Imports:
// ✅ Good (tree-shakeable) import { Button, Input } from 'my-ui-library'; // ❌ Bad (imports everything) import * as UI from 'my-ui-library';
Check Library Support:
// Some libraries don't support tree-shaking // Check before installing: // ✅ Good (tree-shakeable) import { format } from 'date-fns'; // ❌ Bad (not tree-shakeable in older versions) import moment from 'moment';
Optimization Technique #2: Code-Splitting
What is Code-Splitting?
Code-splitting breaks your bundle into smaller chunks that load on-demand.
Before Code-Splitting:
main.js (500 KB) ──┐
├─> User downloads everything
└─> Even unused routes!
After Code-Splitting:
main.js (100 KB) ──> Initial load
routes/about.js (50 KB) ──> Loads when visited
routes/admin.js (80 KB) ──> Loads when visited
Dynamic Imports
React Example:
// ❌ Static import (included in main bundle) import HeavyComponent from './HeavyComponent'; function App() { return <HeavyComponent />; }
// ✅ Dynamic import (loaded on demand) import { lazy, Suspense } from 'react'; const HeavyComponent = lazy(() => import('./HeavyComponent')); function App() { return ( <Suspense fallback={<div>Loading...</div>}> <HeavyComponent /> </Suspense> ); }
Route-Based Splitting
React Router Example:
import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; // Lazy-load route components const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); const Dashboard = lazy(() => import('./pages/Dashboard')); function App() { return ( <BrowserRouter> <Suspense fallback={<LoadingSpinner />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> <Route path="/dashboard" element={<Dashboard />} /> </Routes> </Suspense> </BrowserRouter> ); }
Bundle Analysis:
Before:
main.js: 500 KB
After:
main.js: 120 KB (Home page)
about.chunk.js: 80 KB (loads when /about visited)
dashboard.chunk.js: 180 KB (loads when /dashboard visited)
Improvement: Initial load reduced by 76%!
Component-Level Splitting
Modal Example:
// Modal only used when user clicks button const Modal = lazy(() => import('./Modal')); function App() { const [showModal, setShowModal] = useState(false); return ( <div> <button onClick={() => setShowModal(true)}> Open Modal </button> {showModal && ( <Suspense fallback={null}> <Modal onClose={() => setShowModal(false)} /> </Suspense> )} </div> ); }
Optimization Technique #3: Library Alternatives
Replace Heavy Libraries
Many popular libraries have lightweight alternatives.
Moment.js → date-fns or Day.js:
// ❌ Moment.js (232 KB minified) import moment from 'moment'; const date = moment().format('YYYY-MM-DD'); // ✅ date-fns (13 KB minified, tree-shakeable!) import { format } from 'date-fns'; const date = format(new Date(), 'yyyy-MM-dd'); // ✅ Day.js (7 KB minified) import dayjs from 'dayjs'; const date = dayjs().format('YYYY-MM-DD');
Savings: 225 KB (97% reduction!)
Lodash → Native Methods + Micro Libraries:
// ❌ Lodash (71 KB minified) import _ from 'lodash'; const unique = _.uniq(array); const sorted = _.sortBy(array, 'name'); // ✅ Native + tree-shakeable imports import uniq from 'lodash/uniq'; const unique = [...new Set(array)]; // Native const sorted = array.sort((a, b) => a.name.localeCompare(b.name));
Axios → Fetch API + ky:
// ❌ Axios (13 KB minified) import axios from 'axios'; const { data } = await axios.get('/api/users'); // ✅ Fetch API (native, 0 KB!) const response = await fetch('/api/users'); const data = await response.json(); // ✅ ky (lightweight wrapper, 4 KB) import ky from 'ky'; const data = await ky.get('/api/users').json();
Library Size Comparison Table
| Library | Size (min) | Alternative | Size (min) | Savings |
|---|---|---|---|---|
| Moment.js | 232 KB | date-fns | 13 KB | 95% |
| Lodash (full) | 71 KB | Lodash/fp | 25 KB | 65% |
| jQuery | 87 KB | Vanilla JS | 0 KB | 100% |
| Axios | 13 KB | Fetch API | 0 KB | 100% |
| Chart.js | 236 KB | Recharts | 95 KB | 60% |
Optimization Technique #4: Minification & Compression
Minification
Minification removes unnecessary characters without changing functionality.
Before:
function calculateTotal(items) { let total = 0; for (let i = 0; i < items.length; i++) { total += items[i].price; } return total; }
After (Terser):
function calculateTotal(t){let e=0;for(let l=0;l<t.length;l++)e+=t[l].price;return e}
Webpack Configuration:
// webpack.config.js const TerserPlugin = require('terser-webpack-plugin'); module.exports = { optimization: { minimize: true, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: true, // Remove console.log drop_debugger: true } } }) ] } };
Compression
Gzip vs Brotli:
# Original main.js: 450 KB # Gzipped (default) main.js.gz: 120 KB (73% reduction) # Brotli (better) main.js.br: 95 KB (79% reduction)
Enable Brotli in Next.js:
// next.config.js module.exports = { compress: true, // Enables gzip by default // For Brotli, configure in your hosting (Vercel, Netlify, etc.) // or use a custom server };
Nginx Configuration:
# Enable Brotli brotli on; brotli_types text/plain text/css application/json application/javascript text/xml; brotli_comp_level 6;
Optimization Technique #5: Analyzing Your Bundle
Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: 'bundle-report.html' }) ] };
Run Analysis:
npm run build # Opens interactive treemap visualization
Next.js Bundle Analyzer
npm install @next/bundle-analyzer
// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }); module.exports = withBundleAnalyzer({ // your next.js config });
ANALYZE=true npm run build
What to Look For
Red Flags:
- 🚩 Duplicate dependencies (multiple versions)
- 🚩 Unused libraries (imported but not used)
- 🚩 Heavy libraries (>50 KB for single feature)
- 🚩 Entire library imported when only one function needed
Example Findings:
❌ lodash (71 KB) - only using 2 functions
❌ moment (232 KB) - only for date formatting
❌ react-icons (2.5 MB) - importing all icons
✅ date-fns (13 KB) - tree-shakeable
Real-World Optimization Example
Before Optimization
Bundle Analysis:
main.js: 850 KB (minified)
├── react + react-dom: 140 KB
├── moment.js: 232 KB
├── lodash: 71 KB
├── chart.js: 236 KB
├── react-icons (all): 2.5 MB (tree-shaken to 120 KB)
└── application code: 51 KB
Performance:
- Load Time: 4.2s (3G)
- First Contentful Paint: 2.8s
- Time to Interactive: 5.1s
After Optimization
Changes Made:
- ✅ Replaced Moment.js → date-fns (13 KB)
- ✅ Replaced full Lodash → specific imports (8 KB)
- ✅ Replaced Chart.js → Recharts (95 KB)
- ✅ Implemented route-based code splitting
- ✅ Enabled Brotli compression
New Bundle Analysis:
main.js: 180 KB (minified + brotli)
├── react + react-dom: 140 KB
├── date-fns: 13 KB
├── lodash (tree-shaken): 8 KB
├── application code: 51 KB
└── Code-split routes: 150 KB (loaded on demand)
New Performance:
- Load Time: 1.1s (3G) ✅ 74% faster
- First Contentful Paint: 0.8s ✅ 71% faster
- Time to Interactive: 1.5s ✅ 71% faster
Advanced Optimization Techniques
1. Preloading Critical Assets
<!-- Preload critical JavaScript --> <link rel="preload" href="/main.js" as="script"> <!-- Preload critical CSS --> <link rel="preload" href="/styles.css" as="style"> <!-- Preload fonts --> <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
2. Defer Non-Critical JavaScript
<!-- Defer analytics until after page load --> <script defer src="/analytics.js"></script> <!-- Async for third-party scripts --> <script async src="https://example.com/widget.js"></script>
3. Inline Critical CSS
<!-- Inline critical CSS (above-the-fold styles) --> <style> .header { /* critical styles */ } .hero { /* critical styles */ } </style> <!-- Load rest asynchronously --> <link rel="stylesheet" href="/main.css" media="print" onload="this.media='all'">
4. Remove Unused CSS
PurgeCSS Configuration:
// postcss.config.js module.exports = { plugins: [ require('@fullhuman/postcss-purgecss')({ content: ['./src/**/*.{js,jsx,ts,tsx}'], defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [] }) ] };
Results:
Before: styles.css (450 KB)
After: styles.css (45 KB) ✅ 90% reduction
5. Image Optimization
Images often larger than JavaScript!
// Next.js Image component import Image from 'next/image'; <Image src="/photo.jpg" width={800} height={600} alt="Description" loading="lazy" quality={75} // Reduce quality for smaller file size />
Modern Formats:
- WebP (30% smaller than JPEG)
- AVIF (50% smaller than JPEG)
Performance Budget
Set limits to prevent bundle bloat.
Example Budget:
{ "budgets": [ { "path": "main.js", "maxSize": "150 KB", "maxSizeGzip": "50 KB" }, { "path": "*.css", "maxSize": "50 KB" } ] }
Webpack Performance Hints:
// webpack.config.js module.exports = { performance: { maxAssetSize: 150000, // 150 KB maxEntrypointSize: 150000, hints: 'error' // Fail build if exceeded } };
Monitoring Bundle Size
CI/CD Integration
# GitHub Actions name: Bundle Size Check on: [pull_request] jobs: check-size: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - run: npm ci - run: npm run build - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }}
Size Limit Tool
npm install --save-dev size-limit @size-limit/file
// package.json { "size-limit": [ { "path": "dist/main.js", "limit": "150 KB" } ] }
npm run size-limit # ✓ dist/main.js: 145 KB (within limit)
Testing Bundle Size
Use Perf Lens to measure the impact of your code.
Test Case: Library Comparison
// Test 1: Full Lodash import _ from 'lodash'; const result = _.uniq([1, 2, 2, 3]); // Test 2: Tree-shaken Lodash import uniq from 'lodash/uniq'; const result = uniq([1, 2, 2, 3]); // Test 3: Native const result = [...new Set([1, 2, 2, 3])]; // Compare bundle sizes and performance
Best Practices Checklist
✅ Essential Optimizations
- Enable tree-shaking (ES6 modules)
- Implement code-splitting (routes + components)
- Use dynamic imports for heavy components
- Enable minification (Terser/UglifyJS)
- Enable Brotli compression
- Remove console.log in production
- Analyze bundle with webpack-bundle-analyzer
✅ Library Management
- Review all dependencies regularly
- Replace heavy libraries with alternatives
- Use tree-shakeable libraries
- Import only what you need
- Remove unused dependencies
✅ Monitoring
- Set up performance budget
- Monitor bundle size in CI/CD
- Track Core Web Vitals
- Regular bundle audits (monthly)
Common Mistakes to Avoid
❌ Mistake 1: Importing Entire Libraries
// ❌ Bad (imports 71 KB) import _ from 'lodash'; const result = _.debounce(fn, 300); // ✅ Good (imports 2 KB) import debounce from 'lodash/debounce'; const result = debounce(fn, 300);
❌ Mistake 2: Not Using Code-Splitting
// ❌ Bad (500 KB initial load) import AdminPanel from './AdminPanel'; // 200 KB import ReportGenerator from './ReportGenerator'; // 150 KB // ✅ Good (100 KB initial, load on demand) const AdminPanel = lazy(() => import('./AdminPanel')); const ReportGenerator = lazy(() => import('./ReportGenerator'));
❌ Mistake 3: Including Dev Dependencies in Production
// ❌ Bad { "dependencies": { "react": "^18.0.0", "webpack": "^5.0.0", // Should be devDependency! "@testing-library/react": "^13.0.0" // Should be devDependency! } } // ✅ Good { "dependencies": { "react": "^18.0.0" }, "devDependencies": { "webpack": "^5.0.0", "@testing-library/react": "^13.0.0" } }
Conclusion
Optimizing JavaScript bundle size is crucial for building fast web applications. By implementing these techniques, you can achieve:
Key Takeaways:
- 1. Tree-shaking** eliminates unused code (50-90% reduction)
- 2. Code-splitting** loads code on-demand (70-80% initial load reduction)
- 3. Library alternatives** can save hundreds of KB
- 4. Compression** (Brotli) reduces transfer size by 70-80%
- 5. Monitoring** prevents regression
Optimization Impact:
- Bundle Size: 850 KB → 180 KB (79% reduction)
- Load Time: 4.2s → 1.1s (74% faster)
- User Experience: Dramatically improved
- SEO: Better Core Web Vitals scores
Action Plan:
- 1. Analyze** your current bundle (webpack-bundle-analyzer)
- 2. Replace** heavy libraries with alternatives
- 3. Implement** code-splitting for routes
- 4. Enable** tree-shaking and compression
- 5. Monitor** bundle size in CI/CD
Remember: Every KB counts! A 100 KB reduction can mean the difference between a user staying or bouncing.
Further Reading
- Webpack Bundle Optimization
- Next.js Bundle Analyzer
- Web.dev: Reduce JavaScript Payloads
- Bundlephobia: Check Package Size
Want to measure your bundle's performance impact? Test your code with Perf Lens to see real-world execution time and memory usage differences!