What We're Building and Why
Slow JavaScript bundles kill user experience and tank Core Web Vitals scores. This guide walks you through configuring Webpack 5 with code splitting so your app only loads what the current page actually needs — not a 2MB monolith on every route. You'll need Node.js 18+, basic familiarity with JavaScript modules, and npm installed before starting.
Setting Up the Project
Start with a clean directory and initialize a Node project. We'll install Webpack along with the tools needed to analyze and split our bundle.
mkdir webpack-splitting-demo
cd webpack-splitting-demo
npm init -y
npm install --save-dev webpack webpack-cli webpack-bundle-analyzer html-webpack-plugin
npm install lodash-es date-fns
We're using lodash-es and date-fns as realistic heavy dependencies — the kind that bloat bundles when imported carelessly. The bundle analyzer will make the results visible and concrete.
Create the following project structure manually or with the shell command below:
mkdir -p src/pages src/utils
touch src/index.js src/pages/dashboard.js src/pages/reports.js src/utils/formatter.js webpack.config.js
Your structure should look like this:
webpack-splitting-demo/
├── src/
│ ├── index.js
│ ├── pages/
│ │ ├── dashboard.js
│ │ └── reports.js
│ └── utils/
│ └── formatter.js
├── webpack.config.js
└── package.json
Writing the Application Code
The utility module handles date formatting. It only imports the specific date-fns functions it needs — a pattern that makes tree-shaking effective later.
// src/utils/formatter.js
import { format, parseISO } from "date-fns";
export function formatDate(isoString) {
const parsed = parseISO(isoString);
return format(parsed, "MMMM dd, yyyy");
}
export function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
The dashboard page is a heavier module that pulls in charting logic — simulated here with a lodash-es import to represent real-world weight.
// src/pages/dashboard.js
import { groupBy, sumBy } from "lodash-es";
import { formatCurrency, formatDate } from "../utils/formatter.js";
const transactions = [
{ category: "food", amount: 42.5, date: "2024-11-01" },
{ category: "transport", amount: 15.0, date: "2024-11-02" },
{ category: "food", amount: 28.75, date: "2024-11-03" },
{ category: "utilities", amount: 120.0, date: "2024-11-04" },
];
export function renderDashboard(container) {
const grouped = groupBy(transactions, "category");
const summaryHTML = Object.entries(grouped)
.map(([category, items]) => {
const total = sumBy(items, "amount");
return `
${category}
${formatCurrency(total)}
Last: ${formatDate(items.at(-1).date)}
`;
})
.join("");
container.innerHTML = `Spending Summary
${summaryHTML} `;
}
// src/pages/reports.js
import { formatDate, formatCurrency } from "../utils/formatter.js";
const reportData = [
{ month: "2024-09-01", revenue: 18400, expenses: 12200 },
{ month: "2024-10-01", revenue: 21300, expenses: 14800 },
{ month: "2024-11-01", revenue: 19750, expenses: 11900 },
];
export function renderReports(container) {
const rows = reportData
.map(
({ month, revenue, expenses }) =>
`
${formatDate(month)}
${formatCurrency(revenue)}
${formatCurrency(expenses)}
${formatCurrency(revenue - expenses)}
`
)
.join("");
container.innerHTML = `
Monthly Reports
Month Revenue Expenses Profit
${rows}
`;
}
The entry point is where code splitting actually happens. Dynamic import() tells Webpack to create separate chunks for each page — loaded only when the user navigates to them.
// src/index.js
const appContainer = document.createElement("div");
appContainer.id = "app";
document.body.appendChild(appContainer);
const nav = document.createElement("nav");
nav.innerHTML = `
`;
document.body.prepend(nav);
async function loadPage(route) {
appContainer.innerHTML = "Loading...
";
try {
if (route === "dashboard") {
const { renderDashboard } = await import(
/* webpackChunkName: "page-dashboard" */ "./pages/dashboard.js"
);
renderDashboard(appContainer);
} else if (route === "reports") {
const { renderReports } = await import(
/* webpackChunkName: "page-reports" */ "./pages/reports.js"
);
renderReports(appContainer);
}
} catch (error) {
appContainer.innerHTML = `Failed to load page: ${error.message}
`;
}
}
nav.addEventListener("click", (event) => {
const route = event.target.dataset.route;
if (route) loadPage(route);
});
loadPage("dashboard");
⚠️ Note: The
/* webpackChunkName: "..." */magic comment is not decorative — it sets the output filename for that chunk. Without it, Webpack generates numeric IDs like0.bundle.js, which makes debugging painful.
Configuring Webpack for Code Splitting
Webpack's optimization.splitChunks config is the engine behind automatic chunk generation. We'll also configure the bundle analyzer as a plugin so you can see exactly what's inside each output file.
// webpack.config.js
import path from "path";
import { fileURLToPath } from "url";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isAnalyze = process.env.ANALYZE === "true";
export default {
mode: "production",
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].[contenthash].js",
chunkFilename: "[name].[contenthash].chunk.js",
clean: true,
},
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
vendorLodash: {
test: /[\\/]node_modules[\\/]lodash-es[\\/]/,
name: "vendor-lodash",
chunks: "all",
priority: 20,
},
vendorDateFns: {
test: /[\\/]node_modules[\\/]date-fns[\\/]/,
name: "vendor-date-fns",
chunks: "all",
priority: 20,
},
sharedUtils: {
test: /[\\/]src[\\/]utils[\\/]/,
name: "shared-utils",
chunks: "all",
minChunks: 2,
priority: 10,
},
},
},
},
plugins: [
new HtmlWebpackPlugin({ title: "Webpack Splitting Demo" }),
...(isAnalyze ? [new BundleAnalyzerPlugin()] : []),
],
};
The cacheGroups entries do specific jobs: vendorLodash isolates lodash into its own chunk (so it's cached separately from your app code), and sharedUtils extracts the formatter module since both pages use it — preventing it from being bundled twice.
Add the build scripts to package.json:
{
"type": "module",
"scripts": {
"build": "webpack",
"build:analyze": "ANALYZE=true webpack"
}
}
⚠️ Note: The
"type": "module"field is required because ourwebpack.config.jsuses ES moduleimport/exportsyntax. If you prefer CommonJS, rename the config towebpack.config.cjsand userequire()/module.exportsthroughout.
Testing and Verification
Run the production build and inspect what Webpack generates:
npm run build
You should see output similar to this in your terminal:
asset main.4a3f91bc.js 3.2 KiB
asset page-dashboard.7d2e1a4f.chunk.js 1.4 KiB
asset page-reports.c8b03e12.chunk.js 0.9 KiB
asset vendor-lodash.f2c14a88.chunk.js 68.1 KiB
asset vendor-date-fns.3a91bc7d.chunk.js 18.4 KiB
asset shared-utils.b4e20c11.chunk.js 0.8 KiB
The initial page load only fetches main.js — roughly 3KB. The lodash and date-fns chunks are downloaded only when the user clicks a nav button for the first time, and cached by the browser on subsequent visits.
Verify the split is working correctly by opening Chrome DevTools, going to the Network tab, and filtering by JS. Click the Dashboard button and watch page-dashboard and vendor-lodash load on demand. Click Reports and only page-reports fetches — the date-fns vendor chunk was already cached from the initial dashboard load because both pages share it.
Run the bundle analyzer for a full visual breakdown:
npm run build:analyze
This opens a treemap in your browser showing exactly which modules live inside each chunk. If you see date-fns appearing inside both page-dashboard and page-reports chunks rather than in its own vendor chunk, your cacheGroups config has a priority conflict — increase the vendorDateFns priority value above 20 to resolve it.
⚠️ Note:
contenthashin filenames is critical for cache busting. When you update only the dashboard page code, onlypage-dashboard.[hash].chunk.jsgets a new hash — your users re-download that file alone, not the entire vendor bundle.
What You've Built and Where to Go Next
You now have a Webpack 5 setup that splits vendor libraries into separately cacheable chunks, extracts shared utilities to avoid duplication, and loads page-level code only when users actually need it — all driven by dynamic import() calls and explicit cacheGroups configuration. From here, the natural extensions are adding webpackPrefetch: true comments to preload chunks during browser idle time, setting up webpack-dev-server with hot module replacement for development, and integrating css-minimizer-webpack-plugin to apply the same splitting strategy to your stylesheets.