Large JavaScript bundles are the primary reason for slow "Time to Interactive" metrics on modern websites. When a browser downloads a massive 2MB script, it must also decompress, parse, and execute it, which blocks the main thread and frustrates users. This guide teaches you how to implement code splitting and asset hashing using Webpack to ensure users only download the code they need for the current page. You will need Node.js (v18+) and a basic understanding of ES6 modules to follow along.
Project Environment Setup
Before configuring Webpack, we need to create a controlled environment. We will start with a fresh directory and install the necessary dependencies to build a production-ready bundling pipeline.
mkdir webpack-performance-tutorial
cd webpack-performance-tutorial
npm init -y
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin webpack-bundle-analyzer
npm install lodash
We are installing lodash as a dependency to simulate a heavy third-party library. The webpack-bundle-analyzer tool is essential for visualizing where our bundle space is being consumed.
Now, create the initial project structure. We need an src folder for our logic and a dist folder where Webpack will output the optimized files.
mkdir src
touch src/index.js src/analytics.js src/ui-components.js
touch webpack.config.js
Open your package.json file. Add the following scripts to handle development and production builds easily.
{
"name": "webpack-performance-tutorial",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production",
"stats": "webpack --mode production --json > stats.json && webpack-bundle-analyzer stats.json"
},
"devDependencies": {
"html-webpack-plugin": "^5.6.0",
"webpack": "^5.90.0",
"webpack-bundle-analyzer": "^4.10.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^4.15.0"
},
"dependencies": {
"lodash": "^4.17.21"
}
}
⚠️ Note: Always use --mode production when measuring performance. Webpack applies minification and tree-shaking automatically in production mode, which significantly changes the output size compared to development.
Configuring Asset Hashing and Basic Bundling
The first step in performance is effective caching. If we name our bundle main.js, the browser might cache an old version even after we deploy updates. We fix this by adding a content hash to the filename.
Open webpack.config.js and add the following configuration. This setup tells Webpack to generate a new filename only if the file content changes.
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
title: 'Caching and Splitting',
}),
],
};
The [contenthash] placeholder is a unique string generated based on the file's content. The clean: true property ensures the dist folder is cleared before every build, preventing old files from accumulating.
Now, let's add some code to src/index.js to see this in action. We will import lodash to create a large initial bundle.
import _ from 'lodash';
function component() {
const element = document.createElement('div');
element.innerHTML = _.join(['Hello', 'Webpack'], ' ');
return element;
}
document.body.appendChild(component());
Run npm run build in your terminal. You will see a file like index.7a2f8b...js in your dist folder. If you change a string in index.js and build again, the hash changes, forcing the browser to download the new version.
Implementing Manual and Automatic Code Splitting
Currently, our index.js and the lodash library are bundled together. This is inefficient because third-party libraries change much less frequently than our application code. We want to split them so the user can cache lodash indefinitely.
Modify your webpack.config.js to include the optimization object. This tells Webpack to identify modules that come from node_modules and put them in a separate "vendor" chunk.
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
title: 'Caching and Splitting',
}),
],
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
};
Setting runtimeChunk: 'single' creates a small file that manages the interaction between modules. The moduleIds: 'deterministic' setting ensures that adding a new local module doesn't change the IDs (and thus the hashes) of existing modules.
Run npm run build again. You will now see three files: index.[hash].js, vendors.[hash].js, and runtime.[hash].js. The vendor file will be large, but it will stay cached even if you modify your application logic.
⚠️ Note: Avoid creating too many small chunks. While splitting is good, each HTTP request has overhead (even with HTTP/2). Aim for chunks between 30KB and 100KB for the best balance.
Dynamic Imports for On-Demand Loading
Static splitting is great for caching, but dynamic imports are the secret to true performance. Dynamic imports allow you to load code only when a user performs a specific action, like clicking a button or opening a modal.
First, let's create a module that we don't want in the initial bundle. Open src/analytics.js and add a heavy function simulation.
export function trackEvent(name) {
console.log(`Tracking event: ${name}`);
// Imagine a heavy tracking library is used here
const data = Array.from({ length: 1000 }, (_, i) => `data-point-${i}`);
console.log('Processed analytics data points:', data.length);
}
Now, update src/index.js to load this analytics module only when the user interacts with the page. We use the import() syntax, which returns a Promise.
function createButton() {
const btn = document.createElement('button');
btn.innerHTML = 'Click to Load Analytics';
btn.onclick = async () => {
// This chunk is loaded only after the click
const { trackEvent } = await import(
/* webpackChunkName: "analytics" */ './analytics'
);
trackEvent('UserClickedButton');
};
return btn;
}
document.body.appendChild(createButton());
The comment /* webpackChunkName: "analytics" */ is a "Magic Comment." It tells Webpack to name the generated file analytics.[hash].js instead of a random number like 1.js.
When you run npm start and open the browser, look at the Network tab in DevTools. You will see that analytics.js is not loaded initially. It only appears in the list after you click the button.
Using Prefetching for Predicted Actions
Sometimes you want the best of both worlds: you don't want to load code on the initial page load, but you want it to be ready before the user clicks. Webpack supports "prefetching" to download files during the browser's idle time.
Update the import() call in src/index.js to include the prefetch directive. This is highly effective for code required for the next step in a user flow, like a checkout page or a complex dashboard.
btn.onclick = async () => {
const { trackEvent } = await import(
/* webpackChunkName: "analytics" */
/* webpackPrefetch: true */
'./analytics'
);
trackEvent('UserClickedButtonWithPrefetch');
};
When Webpack sees webpackPrefetch: true, it adds <link rel="prefetch" href="analytics.js"> to the page header. The browser will download this file with low priority after the main page is fully loaded and the CPU is idle.
Verifying Performance and Bundle Composition
To ensure your configuration is working as expected, you must analyze the bundle. We previously installed webpack-bundle-analyzer and added a stats script to package.json.
npm run stats
This command generates a stats.json file and opens a browser window with an interactive treemap. You can see exactly how much space lodash occupies compared to your application code. If you see a library you didn't expect, or if a dynamic chunk contains code that should be shared, you can adjust