Minimizing Bundle Size with React, React Router and Webpack 5
A few years back, I built a SPA containing several large modules, each module representing a major application functionality. I constructed it as a monorepo using Yarn Workspaces, where each module was a package in the repo. The shared modules, like a component library, sat alongside the application modules in their own packages.
Here's what I learned about minimizing the resultant JavaScript bundle sizes.
Why Bundle Size Matters
JavaScript bundle size is a critical factor in web application performance. Large bundles:
- Increase initial load times
- Consume more bandwidth for users
- Impact bounce rates and conversion rates
- Negatively affect SEO rankings
- Degrade user experience, especially on mobile devices
Initial Bundle Metrics
The initial bundled code was ~1.7MB. This consisted of:
- React, React DOM and React Router: ~260KB
- Material-UI: ~400KB
- A large shared component library: ~600KB
- Application-specific code: ~450KB
Code Splitting with React Router
React Router v6 provides a simple method for code splitting using dynamic imports. Instead of importing all components statically:
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
I used React's lazy-loading capability with React Router:
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
Together with React.Suspense:
<Routes>
<Route path="/" element={
<React.Suspense fallback={<Loading />}>
<Home />
</React.Suspense>
} />
{/* Other routes with Suspense */}
</Routes>
Dynamic Imports for Expensive Libraries
Some libraries were only needed in specific parts of the application. For example, a data visualization library was only used in the analytics section.
Instead of:
import { BarChart, LineChart } from 'chart-library';
I used dynamic imports to load them only when needed:
const ChartComponent = () => {
const [Charts, setCharts] = useState(null);
useEffect(() => {
import('chart-library').then(module => {
setCharts(module);
});
}, []);
if (!Charts) return <Loading />;
return <Charts.BarChart data={chartData} />;
};
Webpack Configuration Optimization
1. Bundle Analysis
I used webpack-bundle-analyzer to visualize bundle sizes:
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false,
})
]
}
This helped identify the largest packages and opportunities for splitting.
2. Tree Shaking
Webpack 5 offers improved tree shaking. To ensure it worked effectively, I:
- Used ES modules exclusively (import/export syntax)
- Set
"sideEffects": false
in package.json for relevant packages - Used named exports rather than default exports where possible
- Configured babel-loader to preserve ES modules:
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: false }]
]
}
}
}
3. SplitChunksPlugin Configuration
I optimized Webpack's SplitChunksPlugin to create more efficient chunks:
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 20000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// Create separate chunks for node_modules
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
return `vendor.${packageName.replace('@', '')}`;
},
},
// Common code splitting
common: {
name: 'common',
minChunks: 2,
priority: -10
}
}
}
}
Results
After implementing these optimizations, the application achieved:
- Initial bundle size: ~200KB (88% reduction)
- Time to interactive: Reduced from 4.2s to 1.1s
- Lighthouse performance score: Improved from 67 to 94
The total downloadable JavaScript remained similar, but now it was split into:
- Initial bundle (critical path): ~200KB
- Deferred chunks loaded on demand: ~1.5MB
Conclusion
While modern frameworks like Next.js, Remix, and Vite provide many of these optimizations out of the box, understanding the underlying principles of code splitting and bundle optimization remains valuable when working with custom webpack configurations.
These techniques not only improved initial load times but also enhanced the overall user experience—especially for users on slower connections or mobile devices.
Remember that bundle size optimization is a continuous process. Regularly analyzing your bundles and refining your code-splitting strategy can have significant performance benefits as your application grows.