Fixing Webpack optimization.splitChunks Not Working with React.lazy Dynamic Imports
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH (bundle bloat causes TTI regression, lazy routes never split) | Time to Fix: 15–30 mins
TL;DR
- What broke:
optimization.splitChunksis set toasyncor has acacheGroupsfilter that excludes the dynamic import graph generated byReact.lazy(), so all lazy-loaded components collapse back into the main bundle. - How to fix it: Set
chunks: 'all', ensurecacheGroupsdoes not accidentally filter outnode_modulesor app code that feeds lazy boundaries, and verifyoutput.chunkFilenameis defined so Webpack can name the emitted async chunks. - Sandbox: Use our Client-Side Sandbox below to auto-refactor this — paste your
webpack.config.jsand get a corrected diff without leaking your config to a third-party server.
The Incident (What Does the Error Mean?)
There is no thrown exception. That is what makes this brutal to debug. Your build completes clean:
asset main.js 2.41 MiB [emitted] [minimized]
asset vendors.js 1.89 MiB [emitted] [minimized]
// ← No async chunk for LazyDashboard, LazySettings, etc.
In the Network tab, main.js loads at 4+ MB. The chunks you expected — dashboard.chunk.js, settings.chunk.js — do not exist. React.lazy(() => import('./Dashboard')) resolves instantly because Webpack already inlined Dashboard into the main bundle. Code splitting is completely broken. Your initial page load is carrying every route's code on first paint. Time-to-Interactive on a 4G connection is now 8–12 seconds.
The secondary symptom: in Webpack's --stats output or webpack-bundle-analyzer, you see zero async chunks, or a single 0.chunk.js containing everything.
The Attack Vector / Blast Radius
This is a silent performance regression with a compounding blast radius:
- Bundle size regression is immediate. Every
React.lazyboundary you added to reduce initial load is now dead weight — you're shipping all of it upfront. - CI/CD will not catch it. Build succeeds. Lighthouse CI may not flag it if your performance budget thresholds aren't tight. This ships to production.
- Cache invalidation amplified. Without chunk splitting, a change to any lazy-loaded route invalidates the entire
main.jshash. Users re-download 4 MB on every deploy instead of only the changed 40 KB chunk. - Memory pressure on low-end devices. Parsing a 4 MB JS bundle synchronously on a mid-range Android device causes jank and potential OOM in the JS engine, leading to blank screen renders.
- The root cause is almost always one of three things:
chunksis set to'async'but the dynamic import is not being recognized as truly async (common with certain Babel transforms), acacheGroups.testregex is too greedy and absorbs async modules, oroutput.chunkFilenameis missing so Webpack silently merges unnamed chunks.
How to Fix It (The Solution)
Basic Fix
The minimum viable correction. Confirm chunks: 'all' and a valid chunkFilename:
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
+ chunkFilename: '[name].[contenthash].chunk.js',
},
optimization: {
- splitChunks: {
- chunks: 'async',
- },
+ splitChunks: {
+ chunks: 'all',
+ minSize: 20000,
+ minChunks: 1,
+ maxAsyncRequests: 30,
+ maxInitialRequests: 30,
+ },
},
};
And in your React component, use the webpackChunkName magic comment so the emitted chunk is identifiable:
- const Dashboard = React.lazy(() => import('./Dashboard'));
+ const Dashboard = React.lazy(() => import(/* webpackChunkName: "dashboard" */ './Dashboard'));
Enterprise Best Practice
In a monorepo or large SPA, you need deterministic chunk boundaries, vendor isolation, and framework isolation. A cacheGroups config that is too permissive will re-merge your async chunks. Lock it down:
// webpack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
- chunks: 'async',
- cacheGroups: {
- default: false,
- vendors: {
- test: /[\\/]node_modules[\\/]/,
- name: 'vendors',
- chunks: 'all',
- priority: -10,
- enforce: true,
- },
- },
+ chunks: 'all',
+ minSize: 20000,
+ maxSize: 244000,
+ minChunks: 1,
+ maxAsyncRequests: 30,
+ maxInitialRequests: 30,
+ cacheGroups: {
+ defaultVendors: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendors',
+ chunks: 'all',
+ priority: -10,
+ reuseExistingChunk: true,
+ },
+ react: {
+ test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
+ name: 'react-vendor',
+ chunks: 'all',
+ priority: 20,
+ },
+ default: {
+ minChunks: 2,
+ priority: -20,
+ reuseExistingChunk: true,
+ },
+ },
},
},
Critical: If you are using @babel/plugin-transform-modules-commonjs or babel-plugin-transform-es-modules anywhere in your Babel config, it will convert dynamic import() calls to require() at transpile time, making them synchronous. Webpack never sees a dynamic import boundary. Check your .babelrc or babel.config.js:
// babel.config.js (for webpack builds)
{
"presets": [
["@babel/preset-env", {
- "modules": "commonjs"
+ "modules": false
}]
]
}
Setting modules: false tells Babel to leave ES module syntax intact so Webpack's module graph analysis can identify dynamic import boundaries and split them correctly.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of regression — silent bundle bloat — needs automated gates. Relying on manual bundle inspection is how this ships to production undetected.
1. Webpack Bundle Analyzer as a CI artifact
Add webpack-bundle-analyzer in statsOptions mode and fail the build if no async chunks are emitted:
# In your CI pipeline (GitHub Actions / GitLab CI)
npx webpack --json > stats.json
node -e "
const s = require('./stats.json');
const asyncChunks = s.chunks.filter(c => !c.initial);
if (asyncChunks.length === 0) { console.error('FATAL: No async chunks emitted. React.lazy splitting is broken.'); process.exit(1); }
console.log('OK: ' + asyncChunks.length + ' async chunks emitted.');
"
2. Bundlesize / size-limit enforcement
// package.json
"size-limit": [
{
"path": "dist/main.*.js",
"limit": "500 KB"
}
]
If splitChunks breaks, main.js will exceed 500 KB and the CI step fails. This is your canary.
3. Lighthouse CI with a performance budget
# lighthouserc.yml
assert:
assertions:
total-byte-weight:
- error
- maxNumericValue: 500000
unused-javascript:
- warn
- maxNumericValue: 100000
4. Lock Babel config in CI
Use a separate babel.config.ci.js that explicitly enforces modules: false and reference it in your Webpack build script. Prevent any developer from accidentally enabling CommonJS module transform for the browser build via an environment-specific override.