Code Splitting for Faster Page Loads
A practical guide to code splitting covering dynamic imports, webpack splitChunks, route-based splitting, prefetching, and optimal chunk strategies for faster page loads.
Code Splitting for Faster Page Loads
Overview
Code splitting is the practice of breaking your JavaScript bundle into smaller chunks that load on demand instead of forcing users to download your entire application upfront. A single monolithic bundle means users pay the full cost of every feature before they can interact with anything -- even features they may never use. Code splitting attacks this problem directly by deferring code until it is actually needed.
I have watched applications go from five-second Time to Interactive (TTI) down to under two seconds with nothing more than route-based splitting and a sensible vendor chunk strategy. The performance gains are not theoretical. If your users are on mobile networks or mid-range devices, shipping 400KB of JavaScript they do not need yet is the single biggest performance bottleneck you can fix today.
Prerequisites
- Intermediate JavaScript and module bundler knowledge
- Node.js 16+ installed
- webpack 5 or Vite 4+ (examples cover both)
- Basic understanding of HTTP caching headers
- Chrome DevTools familiarity (Network and Coverage tabs)
Why Code Splitting Matters
The browser cannot render interactive content until it has downloaded, parsed, and executed your JavaScript. Every kilobyte in your initial bundle adds to three critical metrics:
- First Contentful Paint (FCP) -- delayed while the browser fetches large scripts
- Time to Interactive (TTI) -- the main thread is blocked parsing and compiling JavaScript
- Total Blocking Time (TBT) -- long tasks from executing code the user does not need yet
A 500KB JavaScript bundle takes approximately 2-3 seconds to parse and compile on a mid-range mobile device, even after download completes. Code splitting does not reduce the total amount of JavaScript your application contains -- it reduces how much the user must wait for before the page becomes usable.
Entry Point Splitting
The simplest form of code splitting is having multiple entry points. If your site has distinct pages that share some code but not all of it, you can define separate entry points and let webpack extract the common code automatically.
// webpack.config.js
var path = require('path');
module.exports = {
entry: {
home: './src/pages/home.js',
dashboard: './src/pages/dashboard.js',
settings: './src/pages/settings.js'
},
output: {
filename: '[name].[contenthash:8].js',
path: path.resolve(__dirname, 'dist'),
clean: true
}
};
This produces three separate bundles. Each page only loads its own entry point. But there is a problem: if both home.js and dashboard.js import lodash, lodash ends up duplicated in both bundles. That is where splitChunks comes in.
Dynamic import() Syntax
Dynamic import() is the foundation of on-demand code splitting. Unlike static require() calls that pull code into the bundle at build time, import() returns a Promise that resolves to the module when it finishes loading.
// Static -- bundled immediately
var utils = require('./utils');
// Dynamic -- loaded on demand, returns a Promise
function loadChart() {
return import('./chart-module').then(function(module) {
var Chart = module.default;
var chart = new Chart(document.getElementById('canvas'));
chart.render();
});
}
document.getElementById('show-chart').addEventListener('click', function() {
loadChart();
});
The bundler sees the import() call and automatically creates a separate chunk for chart-module.js and all of its dependencies. At runtime, clicking the button triggers a network request for that chunk, and the Promise resolves once it downloads and executes.
Note that import() is the one place where you must use the ES module import keyword by design. There is no CommonJS equivalent for dynamic async loading. The bundler transforms this syntax during the build, so it works regardless of your module format elsewhere.
Webpack splitChunks Configuration
Webpack 5's optimization.splitChunks controls how shared code is extracted into separate chunks. The default configuration is conservative. Here is a production-ready setup I use on most projects:
// webpack.config.js
module.exports = {
// ... entry, output, etc.
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 10,
maxAsyncRequests: 10,
minSize: 20000,
maxSize: 244000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: 10
},
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
name: 'common'
}
}
},
runtimeChunk: 'single'
}
};
Key settings explained:
chunks: 'all'-- Split both synchronous and asynchronous chunks. The default'async'only splits dynamic imports, which misses opportunities to deduplicate shared code across entry points.minSize: 20000-- Do not create chunks smaller than ~20KB. Tiny chunks add HTTP overhead without meaningful caching benefit.maxSize: 244000-- Hint webpack to try splitting chunks larger than ~244KB. This is a soft limit, not a guarantee.runtimeChunk: 'single'-- Extract the webpack runtime into its own chunk. This prevents the runtime code from invalidating your vendor cache on every build.
Vendor Chunk Strategies
The naive approach is a single vendor chunk containing all of node_modules. This works until your vendor bundle hits 500KB+. At that point, a single dependency update invalidates the entire vendor cache for every user.
A better strategy splits vendors by update frequency:
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: 'framework',
chunks: 'all',
priority: 30
},
charting: {
test: /[\\/]node_modules[\\/](chart\.js|d3|recharts)[\\/]/,
name: 'charting',
chunks: 'all',
priority: 20
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: 10
}
}
Framework dependencies update infrequently. Charting libraries are large but only needed on specific pages. Everything else falls into the general vendor chunk. When you update axios, only the vendor chunk cache invalidates. The framework and charting chunks remain cached.
Route-Based Splitting
Route-based splitting is the highest-impact code splitting strategy for most applications. Each route loads only the code it needs. Users visiting the homepage do not download the dashboard code, and vice versa.
// router.js
var routes = {
'/': function() { return import(/* webpackChunkName: "home" */ './pages/home'); },
'/dashboard': function() { return import(/* webpackChunkName: "dashboard" */ './pages/dashboard'); },
'/settings': function() { return import(/* webpackChunkName: "settings" */ './pages/settings'); }
};
function navigate(path) {
var loadRoute = routes[path];
if (!loadRoute) {
loadRoute = function() { return import(/* webpackChunkName: "not-found" */ './pages/not-found'); };
}
var container = document.getElementById('app');
container.innerHTML = '<div class="loading-spinner">Loading...</div>';
loadRoute().then(function(module) {
var render = module.default;
render(container);
}).catch(function(err) {
container.innerHTML = '<div class="error">Failed to load page. Please refresh.</div>';
console.error('Route load failed:', err);
});
}
Every route definition is a function that returns a dynamic import(). The chunk is only fetched when the user navigates to that route. The magic comment /* webpackChunkName: "home" */ controls the output filename -- without it, you get opaque numeric filenames like 42.js.
Component-Based Splitting
Beyond routes, you can split at the component level for heavy features that appear conditionally. Think modals, rich text editors, data visualization widgets, or admin panels.
// Load heavy editor only when user clicks "Edit"
function openEditor(elementId) {
var target = document.getElementById(elementId);
target.innerHTML = '<p>Loading editor...</p>';
import(/* webpackChunkName: "rich-editor" */ './components/rich-editor')
.then(function(module) {
var RichEditor = module.default;
var editor = new RichEditor(target);
editor.init();
})
.catch(function(err) {
target.innerHTML = '<p>Failed to load editor. <button onclick="openEditor(\'' + elementId + '\')">Retry</button></p>';
console.error(err);
});
}
The rule of thumb: if a component adds more than 30KB to the bundle and is not visible on initial load, split it.
Prefetching and Preloading Split Chunks
Dynamic imports load code on demand, but that means the user waits for a network request at the moment they need the feature. Prefetching and preloading bridge this gap.
webpackPrefetch
Prefetch tells the browser to fetch the chunk during idle time, after the current page finishes loading. The browser adds a <link rel="prefetch"> tag to the document head.
// Prefetch dashboard chunk while user is on the homepage
import(/* webpackChunkName: "dashboard", webpackPrefetch: true */ './pages/dashboard');
This generates:
<link rel="prefetch" href="/static/dashboard.a1b2c3d4.js">
The browser downloads this at lowest priority during idle time. When the user navigates to /dashboard, the chunk is already in the cache. Prefetch is ideal for routes the user is likely to visit next.
webpackPreload
Preload fetches the chunk in parallel with the current chunk, at high priority. Use this for code that is needed during the current route's rendering but is split for caching reasons.
// Inside the dashboard page -- preload the chart library needed immediately
import(/* webpackChunkName: "charting", webpackPreload: true */ './lib/charting');
Be careful with preload. Overusing it defeats the purpose of code splitting because you end up loading everything at high priority again.
Prefetch on Hover
For fine-grained control, trigger prefetch when the user hovers over a navigation link:
function setupPrefetchOnHover() {
var prefetched = {};
var links = document.querySelectorAll('[data-prefetch]');
for (var i = 0; i < links.length; i++) {
(function(link) {
link.addEventListener('mouseenter', function() {
var route = link.getAttribute('data-prefetch');
if (prefetched[route]) return;
prefetched[route] = true;
var loader = routes[route];
if (loader) {
loader(); // triggers the import(), browser caches the response
}
});
})(links[i]);
}
}
<nav>
<a href="/dashboard" data-prefetch="/dashboard">Dashboard</a>
<a href="/settings" data-prefetch="/settings">Settings</a>
</nav>
Users typically hover over a link 100-300ms before clicking. That is enough time to start (and often complete) the chunk download.
Loading States for Lazy-Loaded Modules
Never leave the user staring at a blank screen while a chunk loads. Every dynamic import needs a loading indicator:
function showLoadingState(container) {
var overlay = document.createElement('div');
overlay.className = 'chunk-loading';
overlay.innerHTML = '<div class="spinner"></div><p>Loading...</p>';
container.appendChild(overlay);
return overlay;
}
function removeLoadingState(overlay) {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
.chunk-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
For chunks that load in under 200ms, showing a spinner creates visual noise. Consider adding a slight delay before showing the loading indicator:
function loadWithDelay(importFn, container, delay) {
var showSpinner = false;
var overlay = null;
var timer = setTimeout(function() {
showSpinner = true;
overlay = showLoadingState(container);
}, delay || 200);
return importFn().then(function(module) {
clearTimeout(timer);
if (showSpinner) removeLoadingState(overlay);
return module;
});
}
Error Handling and Retry Patterns
Network requests fail. Chunk loads are network requests. You need retry logic:
function loadWithRetry(importFn, retries, delay) {
retries = retries || 3;
delay = delay || 1000;
return importFn().catch(function(err) {
if (retries <= 0) {
throw err;
}
console.warn('Chunk load failed, retrying in ' + delay + 'ms (' + retries + ' remaining)');
return new Promise(function(resolve) {
setTimeout(resolve, delay);
}).then(function() {
return loadWithRetry(importFn, retries - 1, delay * 2);
});
});
}
// Usage
loadWithRetry(
function() { return import(/* webpackChunkName: "dashboard" */ './pages/dashboard'); },
3,
1000
).then(function(module) {
module.default(document.getElementById('app'));
}).catch(function(err) {
document.getElementById('app').innerHTML =
'<div class="error">' +
'<h2>Something went wrong</h2>' +
'<p>Please check your connection and <a href="javascript:location.reload()">reload the page</a>.</p>' +
'</div>';
});
The exponential backoff (doubling the delay each retry) prevents hammering a struggling server. Three retries with 1s, 2s, 4s delays covers most transient network issues.
Chunk Naming with Magic Comments
Webpack magic comments give you control over chunk behavior:
// Name the chunk
import(/* webpackChunkName: "user-profile" */ './components/UserProfile');
// Group multiple imports into one chunk
import(/* webpackChunkName: "admin" */ './admin/users');
import(/* webpackChunkName: "admin" */ './admin/roles');
import(/* webpackChunkName: "admin" */ './admin/settings');
// Exclude from bundle (use externally loaded library)
import(/* webpackIgnore: true */ 'https://cdn.example.com/lib.js');
// Control loading mode
import(/* webpackMode: "lazy" */ './heavy-module'); // default, separate chunk
import(/* webpackMode: "eager" */ './always-needed'); // no separate chunk, still async API
Grouping related imports under the same webpackChunkName is useful for admin panels or feature groups where the user will likely need all of them together.
Analyzing Chunk Composition
You cannot optimize what you cannot measure. Use webpack-bundle-analyzer to visualize your chunks:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false
})
]
};
Run the build and open bundle-report.html. Look for:
- Duplicated modules across chunks (the same library appearing in multiple colors)
- Unexpectedly large chunks (a "settings" page that pulls in a charting library)
- Vendor code in page chunks (missing splitChunks configuration)
For quick size checks without the full visualizer:
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json dist/ --mode static
Optimal Chunk Size Guidelines
After years of profiling production applications, these are the chunk size targets I aim for:
| Chunk Type | Target Size (gzipped) | Rationale |
|---|---|---|
| Initial (critical path) | < 50KB | First paint and TTI |
| Route chunks | 30-80KB | Per-page code |
| Vendor (framework) | 40-80KB | Infrequent updates, high cache hit |
| Feature chunks | 20-60KB | On-demand features |
| Total initial download | < 170KB | Covers most 3G budgets |
Chunks under 10KB gzipped add more HTTP overhead than they save. Chunks over 150KB gzipped start impacting parse time on mobile devices.
HTTP/2 and Chunk Strategy
HTTP/2 multiplexing changed the cost calculus for multiple requests. Under HTTP/1.1, each chunk required a separate TCP connection (limited to 6 per domain), making many small chunks expensive. Under HTTP/2, all requests share one connection.
This means:
- HTTP/2: Favor more, smaller chunks. 15-20 chunks loading in parallel is fine. Better caching granularity because updating one dependency does not invalidate unrelated chunks.
- HTTP/1.1: Favor fewer, larger chunks. Keep initial requests under 6. Combine vendor dependencies aggressively.
Check your server supports HTTP/2 (most CDNs and modern servers do). If you must support HTTP/1.1 clients, set maxInitialRequests to 4-6 in your splitChunks config.
Caching Strategy with Content Hashes
Code splitting only delivers long-term performance gains if chunks are cached properly. Use content hashes in filenames so unchanged chunks remain cached across deployments:
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
path: path.resolve(__dirname, 'dist')
}
Set long cache headers on your static assets:
Cache-Control: public, max-age=31536000, immutable
The contenthash changes only when the file contents change. Combined with runtimeChunk: 'single', updating one page's code does not bust the cache for vendor chunks, the runtime, or other pages.
Code Splitting in Vite and Rollup
Vite uses Rollup under the hood and handles code splitting with less configuration. Dynamic import() works out of the box:
// vite.config.js
var defineConfig = require('vite').defineConfig;
module.exports = defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: function(id) {
if (id.indexOf('node_modules') !== -1) {
if (id.indexOf('chart.js') !== -1 || id.indexOf('d3') !== -1) {
return 'charting';
}
return 'vendor';
}
}
}
},
chunkSizeWarningLimit: 250
}
});
Rollup's manualChunks function gives you fine-grained control. The function receives each module ID and returns a chunk name. Returning undefined lets Rollup decide automatically.
Vite's dev server uses native ES modules and skips bundling entirely during development, so code splitting configuration only applies to production builds.
Complete Working Example
Here is a multi-page vanilla JavaScript application with route-based code splitting, loading indicators, error handling with retry, prefetching on hover, and proper caching.
Project Structure
project/
src/
index.js # Entry point and router
router.js # Route-based code splitting logic
pages/
home.js # Home page module
dashboard.js # Dashboard page (heavy charting)
settings.js # Settings page
lib/
chart.js # Chart rendering utility
webpack.config.js
package.json
index.html
package.json
{
"name": "split-demo",
"scripts": {
"build": "webpack --mode production",
"dev": "webpack serve --mode development",
"analyze": "webpack --mode production --env analyze"
},
"devDependencies": {
"html-webpack-plugin": "^5.5.0",
"webpack": "^5.88.0",
"webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^4.15.0"
},
"dependencies": {
"chart.js": "^4.4.0"
}
}
webpack.config.js
var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = function(env) {
var plugins = [
new HtmlWebpackPlugin({
template: './index.html',
filename: 'index.html'
})
];
if (env && env.analyze) {
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins.push(new BundleAnalyzerPlugin());
}
return {
entry: './src/index.js',
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
clean: true
},
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 10,
minSize: 20000,
cacheGroups: {
charting: {
test: /[\\/]node_modules[\\/](chart\.js)[\\/]/,
name: 'charting',
chunks: 'all',
priority: 20
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: 10
}
}
},
runtimeChunk: 'single'
},
plugins: plugins,
devServer: {
historyApiFallback: true,
port: 3000,
headers: {
'Cache-Control': 'public, max-age=31536000, immutable'
}
}
};
};
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Split Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; color: #333; }
nav { background: #2c3e50; padding: 1rem 2rem; display: flex; gap: 1.5rem; }
nav a { color: #ecf0f1; text-decoration: none; font-weight: 500; }
nav a:hover { color: #3498db; }
#app { padding: 2rem; max-width: 960px; margin: 0 auto; }
.chunk-loading { display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 300px; }
.spinner { width: 40px; height: 40px; border: 3px solid #e0e0e0;
border-top-color: #3498db; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { background: #fef0f0; border: 1px solid #f5c6cb; padding: 1.5rem;
border-radius: 4px; text-align: center; }
.error a { color: #3498db; }
</style>
</head>
<body>
<nav>
<a href="/" data-link data-prefetch="/">Home</a>
<a href="/dashboard" data-link data-prefetch="/dashboard">Dashboard</a>
<a href="/settings" data-link data-prefetch="/settings">Settings</a>
</nav>
<div id="app"></div>
</body>
</html>
src/index.js
var Router = require('./router');
var router = new Router(document.getElementById('app'));
// Handle click navigation
document.addEventListener('click', function(e) {
var link = e.target.closest('[data-link]');
if (link) {
e.preventDefault();
var path = link.getAttribute('href');
history.pushState(null, '', path);
router.navigate(path);
}
});
// Handle browser back/forward
window.addEventListener('popstate', function() {
router.navigate(location.pathname);
});
// Setup prefetch on hover
router.setupPrefetchOnHover();
// Initial route
router.navigate(location.pathname);
src/router.js
var routes = {
'/': function() { return import(/* webpackChunkName: "home", webpackPrefetch: true */ './pages/home'); },
'/dashboard': function() { return import(/* webpackChunkName: "dashboard" */ './pages/dashboard'); },
'/settings': function() { return import(/* webpackChunkName: "settings" */ './pages/settings'); }
};
function Router(container) {
this.container = container;
this.prefetched = {};
}
Router.prototype.navigate = function(path) {
var self = this;
var loadRoute = routes[path];
if (!loadRoute) {
self.container.innerHTML = '<div class="error"><h2>Page Not Found</h2></div>';
return;
}
var spinnerTimer = null;
var spinnerShown = false;
// Show loading spinner after 200ms delay
spinnerTimer = setTimeout(function() {
spinnerShown = true;
self.container.innerHTML =
'<div class="chunk-loading"><div class="spinner"></div><p>Loading...</p></div>';
}, 200);
self._loadWithRetry(loadRoute, 3, 1000)
.then(function(module) {
clearTimeout(spinnerTimer);
var render = module.default;
render(self.container);
})
.catch(function(err) {
clearTimeout(spinnerTimer);
console.error('Failed to load route:', err);
self.container.innerHTML =
'<div class="error">' +
'<h2>Failed to load page</h2>' +
'<p>Check your connection and <a href="javascript:location.reload()">reload</a>.</p>' +
'</div>';
});
};
Router.prototype._loadWithRetry = function(importFn, retries, delay) {
var self = this;
return importFn().catch(function(err) {
if (retries <= 0) throw err;
console.warn('Chunk load failed, retrying in ' + delay + 'ms (' + retries + ' left)');
return new Promise(function(resolve) {
setTimeout(resolve, delay);
}).then(function() {
return self._loadWithRetry(importFn, retries - 1, delay * 2);
});
});
};
Router.prototype.setupPrefetchOnHover = function() {
var self = this;
var links = document.querySelectorAll('[data-prefetch]');
for (var i = 0; i < links.length; i++) {
(function(link) {
link.addEventListener('mouseenter', function() {
var route = link.getAttribute('data-prefetch');
if (self.prefetched[route]) return;
self.prefetched[route] = true;
var loader = routes[route];
if (loader) {
loader(); // triggers import(), browser caches the chunk
}
});
})(links[i]);
}
};
module.exports = Router;
src/pages/home.js
function render(container) {
container.innerHTML =
'<h1>Home</h1>' +
'<p>Welcome to the code splitting demo. This page loaded instantly because ' +
'it was prefetched. Navigate to Dashboard to see lazy-loaded charting.</p>' +
'<p>Hover over the nav links -- the chunks prefetch on hover.</p>';
}
module.exports.default = render;
src/pages/dashboard.js
function render(container) {
container.innerHTML =
'<h1>Dashboard</h1>' +
'<p>Loading chart data...</p>' +
'<canvas id="chart-canvas" width="600" height="300"></canvas>';
// Second-level split: chart.js only loads when dashboard is visited
import(/* webpackChunkName: "chart-render" */ '../lib/chart')
.then(function(module) {
var renderChart = module.default;
renderChart(document.getElementById('chart-canvas'));
})
.catch(function(err) {
console.error('Chart failed to load:', err);
document.getElementById('chart-canvas').parentNode.innerHTML +=
'<p class="error">Chart failed to load.</p>';
});
}
module.exports.default = render;
src/pages/settings.js
function render(container) {
container.innerHTML =
'<h1>Settings</h1>' +
'<form id="settings-form">' +
'<label>Display Name<br><input type="text" name="name" value="Shane"></label><br><br>' +
'<label>Theme<br><select name="theme">' +
'<option>Light</option><option>Dark</option></select></label><br><br>' +
'<button type="submit">Save</button>' +
'</form>';
document.getElementById('settings-form').addEventListener('submit', function(e) {
e.preventDefault();
alert('Settings saved (demo)');
});
}
module.exports.default = render;
src/lib/chart.js
var Chart = require('chart.js/auto');
function renderChart(canvas) {
new Chart(canvas, {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Monthly Revenue ($K)',
data: [12, 19, 8, 25, 15, 30],
backgroundColor: '#3498db'
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } }
}
});
}
module.exports.default = renderChart;
Build Output
$ npm run build
asset runtime.4f2a8b1c.js 1.2 KB [emitted] (runtime)
asset home.d3e5f7a9.chunk.js 0.4 KB [emitted]
asset settings.b1c2d3e4.chunk.js 0.6 KB [emitted]
asset dashboard.a9b8c7d6.chunk.js 0.8 KB [emitted]
asset chart-render.e5f6a7b8.chunk.js 2.1 KB [emitted]
asset charting.f1e2d3c4.js 198.0 KB [emitted] (chart.js)
asset vendor.c4d5e6f7.js 3.8 KB [emitted]
asset main.7a8b9c0d.js 2.4 KB [emitted]
Initial load: main.js + runtime.js + vendor.js = ~7.4 KB
Dashboard visit adds: dashboard.js + chart-render.js + charting.js = ~201 KB
Settings visit adds: settings.js = ~0.6 KB
The user visiting the homepage downloads 7.4KB. The 198KB chart.js library only loads when they visit the dashboard.
Common Issues and Troubleshooting
1. ChunkLoadError: Loading chunk X failed
Uncaught (in promise) ChunkLoadError: Loading chunk dashboard failed.
(error: https://example.com/dashboard.a9b8c7d6.chunk.js)
This happens when a deployment changes chunk hashes but the user's page still references old filenames. The HTML page was cached with the old <script> tags. Fix: implement the retry logic shown above, and on final failure, prompt the user to reload. Set appropriate Cache-Control headers on your HTML file (no-cache for HTML, immutable for hashed assets).
2. Duplicated Modules Across Chunks
Run webpack-bundle-analyzer and look for the same module appearing in multiple chunk colors. This usually means your splitChunks.minSize is too high (the shared module is below the threshold for extraction) or chunks is set to 'async' instead of 'all'. Lower minSize or explicitly configure a cache group for the duplicated module.
3. Chunk Loads Before It Is Needed (Eager Loading)
If you see chunks loading on page load despite using import(), check for two issues: (1) a static require() or import somewhere in the dependency chain that pulls the module in eagerly, or (2) webpackPreload being used where webpackPrefetch was intended. Preload triggers immediately at high priority. Prefetch triggers during idle time.
4. Magic Comments Stripped by Minifier
If your chunk names are not appearing in the output filenames, your minifier may be stripping comments. Ensure Terser is configured to preserve webpack magic comments:
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
output: {
comments: /webpackChunkName/
}
},
extractComments: false
})
]
}
5. Dynamic Path Expressions Break Splitting
// BAD -- webpack cannot statically analyze this
var page = 'dashboard';
import('./pages/' + page);
// BETTER -- use webpackInclude to constrain
import(
/* webpackInclude: /\.(js)$/ */
/* webpackChunkName: "page-[request]" */
'./pages/' + page
);
Fully dynamic paths force webpack to create a chunk for every file in the directory. Use webpackInclude and webpackExclude to narrow the scope.
Best Practices
Start with route-based splitting. It delivers the biggest impact with the least complexity. Split every top-level route into its own chunk before doing anything else.
Always include loading states and error handling. Every
import()call is a network request that can fail or take seconds on slow connections. Users should never see a blank screen.Use content hashes in filenames. Without
[contenthash], every deployment invalidates the browser cache for all chunks, even those with no changes. Content hashes give you free long-term caching.Measure before and after. Use Chrome DevTools Coverage tab to see how much of your JavaScript is unused on initial load. Use Lighthouse to track TTI and TBT. Measure real user metrics with web-vitals, not just synthetic benchmarks.
Do not over-split. Splitting a 5KB utility into its own chunk adds HTTP overhead and provides negligible caching benefit. Set a minimum chunk size threshold of 20KB and stick to it.
Separate vendor chunks by update frequency. Framework dependencies (React, Vue) rarely change. Put them in their own chunk separate from frequently-updated utility libraries. This maximizes cache hit rates.
Prefetch predictable navigation paths. If 80% of users go from the homepage to the dashboard, prefetch the dashboard chunk from the homepage. Use analytics data to identify these patterns, not guesses.
Extract the runtime chunk. Always set
runtimeChunk: 'single'. The webpack runtime contains chunk loading logic that changes on every build. Without extracting it, your vendor chunk's content hash changes every build even if no vendor code changed.