How We Cut Our React App's Bundle Size in Half
How we cut a React app's bundle from 1.54MB gzipped roughly in half: code-splitting, lazy loading, and dependency audits that actually moved the needle.
How We Cut Our React App's Bundle Size in Half
Our React app's bundle hit 1.54MB gzipped, and loading times kept creeping up. Profiling showed why: users parsed code they didn't need.
Understanding the Problem
Lighthouse and Chrome DevTools showed it clearly:
- Pages took too long to become interactive
- Mobile users faced significant load delays
- Users downloaded and parsed more code than they needed for basic interactions
The Optimization Strategy
1. Route-Based Code Splitting
In our app using createBrowserRouter from React Router 6, we implemented route-based code-splitting to defer code loading until a user navigates to the relevant route:
import React, { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const LazyLoadedPage = lazy(() => import("./LazyLoadedPage"));
const router = createBrowserRouter([
{
path: "/feature",
element: (
<Suspense fallback={<div>Loading...</div>}>
<LazyLoadedPage />
</Suspense>
),
},
]);2. Strategic Library Loading
Move the import inside the function that uses it, and the dependency only loads when needed:
// Before - importing at the top level
import { PDFDocument } from "@react-pdf/renderer";
import * as XLSX from "xlsx";
// After - inline imports when needed
async function generatePDF(data) {
const { PDFDocument } = await import("@react-pdf/renderer");
// PDF generation logic
}
async function exportToExcel(data) {
const XLSX = await import("xlsx");
// Excel export logic
}We applied this pattern to several large dependencies:
- PDF generation (
@react-pdf, ~450KB) → loads only during PDF exports - Spreadsheet functionality (
xlsx, ~214KB) → waits for export operations - Date handling → moved to lighter alternatives for common operations
Other common libraries worth deferring in React apps:
- Rich text editors (
draft-js,quill) → load when editing starts - Chart libraries (
chart.js,recharts) → load when viewing data visualizations - Complex UI components (
@mui/x-data-grid,ag-grid) → load when data tables mount - Image manipulation (
sharp,jimp) → load when editing starts
The pattern is the same: identify when users need the functionality, and load it at that moment. This matters most during initial page loads.
3. Measurement-Driven Optimization
Three tools drove our decisions:
source-map-exploreranalyzed dependency sizes- Chrome DevTools' Performance tab showed load behavior
- Regular Lighthouse audits tracked improvements
The Results
| Metric | Change |
|---|---|
| Main Bundle Size | −49.5% |
| First Contentful Paint | −28.5% |
| Total Blocking Time | −20% |
| Speed Index | −46.4% |
Key Learnings
Target the right components for code splitting. Focus on larger features that deliver meaningful impact.
Performance tools reveal opportunities you might miss otherwise. Regular bundle analysis became essential to our process.
Each improvement reveals the next one.
Maintaining Performance
Three practices keep our bundle size in check:
Regular bundle analysis during development
Performance budgets for new feature development, including:
- Maximum initial bundle size: 500KB after gzip
- Route-level budgets: 200KB per lazy-loaded route
- Time-to-Interactive budget: < 3.5s on 4G connections
- First Contentful Paint target: < 1.8s
- Third-party script size limit: 100KB total
- Image size budgets: 200KB max per image, WebP format required
- These budgets are enforced through:
- webpack configuration using
performance.hints size-limitchecks in CI- Lighthouse performance scoring thresholds
- webpack configuration using
Automated performance testing in our CI pipeline using:
- Lighthouse CI for performance metrics
- Bundle size tracking with
bundlewatch - Jest Timer snapshots for runtime performance
webpack-bundle-analyzerin build pipelines- GitHub Actions workflows triggered on PR deployments
Conclusion
Performance optimization delivers real user value. We cut the bundle by measuring first and splitting the heavy libraries out. The bundle now loads at about 777KB gzipped instead of 1.54MB.
Every kilobyte shows up in loading time.