Modern Web Platform
This guide describes practical webpack patterns for Web Components, Import Maps, and Progressive Web Apps (PWAs) with Service Workers. Each section states the problem, shows a minimal configuration you can copy, and notes current limits relative to future webpack improvements.
Web Components with webpack
Problem
If more than one JavaScript bundle executes customElements.define() for the same tag name, the browser throws DOMException: Failed to execute 'define' on 'CustomElementRegistry'. That often happens when the module that registers an element is duplicated: separate entry points or async chunks each contain a copy of the registration code, so two bundles both run define for the same tag.
Approach
Use optimization.splitChunks so the module that defines the element lives in a single shared chunk loaded once. Adjust cacheGroups so your element definitions (or a dedicated folder such as src/elements/) are forced into one chunk. See Prevent Duplication for the general idea.
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: {
main: "./src/main.js",
admin: "./src/admin.js",
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist"),
clean: true,
},
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
// Put shared custom element modules in one async chunk.
customElements: {
test: /[\\/]src[\\/]elements[\\/]/,
name: "custom-elements",
chunks: "all",
enforce: true,
},
},
},
},
};Ensure both entries import the same registration module (for example ./elements/my-element.js) so webpack can emit one custom-elements.js chunk instead of inlining duplicate registration in main and admin.
Limitations and future work
Splitting alone does not change browser rules: the tag name must still be registered exactly once per document. Webpack does not yet provide a first-class “register this custom element once” primitive beyond chunk graph control. Native support for deduplicating custom element registration across the build is planned; until then, rely on shared chunks and a single registration module.
Import Maps with webpack
Problem
Import maps let the browser resolve bare specifiers (import "lodash-es" from importmap.json or an inline <script type="importmap">). If webpack bundles those dependencies, you do not need an import map for them. If you want the browser to load a dependency from a URL (CDN or /vendor/) while your application code keeps bare imports, mark those modules as externals so webpack emits import statements that match your map.
Approach
Enable ES module output (experiments.outputModule and output.module), set externalsType: "module" for static imports, and list each bare specifier in externals with the same string the browser will resolve via the import map.
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: "production",
experiments: {
outputModule: true,
},
entry: "./src/index.js",
externalsType: "module",
externals: {
"lodash-es": "lodash-es",
},
output: {
module: true,
filename: "[name].mjs",
path: path.resolve(__dirname, "dist"),
clean: true,
},
};importmap.json (served alongside your HTML; URLs must match your deployment)
{
"imports": {
"lodash-es": "/assets/lodash-es.js"
}
}The key "lodash-es" must match both the externals key and the specifier in your source (import … from "lodash-es"). The value is the URL the browser loads; webpack does not validate that file.
index.html (order matters: import map before your bundle)
<script type="importmap" src="/importmap.json"></script>
<script type="module" src="/dist/main.mjs"></script>Limitations and future work
Webpack does not emit or update importmap.json for you. You must maintain the map so specifiers and URLs stay aligned with externals and your server layout. Automatic import-map generation is not available in webpack 5 today; future tooling may reduce this manual step.
Progressive Web Apps (PWA) and Service Workers
Problem
Long-lived caching requires stable URLs for HTML but versioned URLs for scripts and styles. Using [contenthash] in output.filename changes those URLs every build. A service worker precache list must list the exact URLs after each build, or offline shells will point at missing files.
The workbox-webpack-plugin GenerateSW plugin generates an entire service worker for you. That is convenient, but when you need full control over service worker code (custom routing, skipWaiting behavior, or coordination with [contenthash] and other plugins), InjectManifest is appropriate: you write the worker, and Workbox injects the precache manifest at build time from webpack’s asset list.
Approach
Use [contenthash] for emitted assets and add InjectManifest from workbox-webpack-plugin. Your source template imports workbox-precaching and calls precacheAndRoute(self.__WB_MANIFEST); the plugin replaces self.__WB_MANIFEST with the list of webpack assets (including hashed filenames).
Install:
npm install workbox-webpack-plugin workbox-precaching --save-devwebpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { InjectManifest } from "workbox-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: "./src/index.js",
output: {
filename: "[name].[contenthash].js",
path: path.resolve(__dirname, "dist"),
clean: true,
},
plugins: [
new HtmlWebpackPlugin({ title: "PWA + content hashes" }),
new InjectManifest({
swSrc: path.resolve(__dirname, "src/service-worker.js"),
swDest: "service-worker.js",
}),
],
};src/service-worker.js (precache template)
import { precacheAndRoute } from "workbox-precaching";
// Replaced at build time with webpack's precache manifest (hashed asset URLs).
precacheAndRoute(globalThis.__WB_MANIFEST);Register the emitted service-worker.js from your app (for example in src/index.js) with navigator.serviceWorker.register("/service-worker.js"), served from dist/ with the correct scope.
Limitations and future work
You must keep InjectManifest in sync with your output filenames and plugins; GenerateSW remains the simpler path when you do not need a custom worker. Webpack does not ship a built-in service worker precache generator; tighter integration with hashed assets may arrive in future releases. Until then, Workbox’s InjectManifest is a well-supported way to align [contenthash] output with precaching.



