High-traffic consumer public site
Design system runtime integration
Moved the design system and framework runtime out of three application bundles and onto a CDN, resolved by the browser from one import map. Webpack externals are derived from that map, so an unresolvable import can't ship, and a design-system upgrade is a version bump in one JSON file rather than a rebuild of three apps.
The problem
After the split into three independently deployed apps, each one was still built from the same web-component design system. Bundled the ordinary way, that means three copies of every design-system package, three copies of the element base class, and three attempts to register the same custom elements in a registry that allows only one — a second define() of a tag throws.
The approach
Shared dependencies stopped being build inputs. Each one is a webpack external that compiles to an untouched bare specifier, and an import map injected at build time tells the browser which immutable URL each specifier means. The bundler stops resolving; the platform resolves.
┌───────────┐ ┌───────────┐ ┌───────────┐
│ shell │ │ retail │ │ business │ three independent builds
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
│ externals derived from the map's keys — every one of them
│ compiles to a bare specifier webpack never rewrites
▼ ▼ ▼
import '@ds/button' · import 'lit' · import 'common'
└──────────────────┬──────────────────┘
▼
<script type="importmap"> injected into <head> at build time
package specifiers → version-pinned, immutable CDN URLs
federation entries → same map, resolved the same way
</script>
│
▼
browser module loader — one resolution,
one instance, shared by all three appsKey decisions
The map generates the externals
Hand-written externals fail in a nasty way: a specifier survives the build with nothing to resolve it, and the page dies at runtime on Failed to resolve module specifier — only on the route that uses it. Here the import map is a checked-in JSON file, and the webpack config derives its externals from the map's keys. You can't externalize a package without giving it a resolution rule, or add a rule that isn't externalized. The failure mode isn't handled; it's unrepresentable.
The import external type resolves each specifier with a real runtime import(), which makes every consuming module async and requires experiments.outputModule. That was acceptable because the entry is already a module script and every page already arrives through a dynamic import — the asynchrony was already in the graph.
The same map resolves federation remotes
Some entries don't point at a CDN. Two are the shell's own federation containers, and a trailing-slash prefix serves legacy polyfills from the app's own origin. Remotes are declared with the same bare-specifier form as any CDN package, so where a remote lives becomes a deployment-time fact instead of a compiled-in constant, and there is one mechanism to debug when something fails to load.
One map, injected first, by a plugin
An import map only governs resolutions that happen after it is parsed, and for most of this system's life browsers allowed one map per document. So a small build plugin assembles the map centrally and prepends it to <head> through the HTML plugin's pre-emit hook, ahead of the module script. The JSON is registered as a file dependency, so a version bump invalidates the HTML in watch mode.
Testing a release candidate without a deploy
Immutable URLs make upgrades safe but slow to validate: bump, rebuild, deploy, for every round of feedback. Non-production builds therefore emit an inline script instead of a static map. When a flag is set — from localStorage, a query parameter or a floating toggle — the script rewrites every design-system URL to the next channel and then writes the import map itself, still ahead of the module scripts.
The script is a serialized function, so its closure doesn't travel with it: every helper has to be defined inside it, with no bundler to catch mistakes. And because import maps don't cover CSS, it also watches <head> with a MutationObserver and repoints design-system stylesheet links until DOMContentLoaded, so a candidate is never tested as new components on old tokens.
Two resolution graphs, one version number
Externalized packages still live in node_modules for the editor, linter and tests, while the browser resolves them through the map. Two graphs describing one dependency will drift — invisibly, with tests passing on one version while users run another. So one script writes each version bump to both the import map and package.json, or to neither.
The release loop it created
The design-system team publishes a candidate to next. QA flips the toggle on the test or beta environment — the deployed app as it already stands, with only the design-system components swapped for the candidate — and signs off, with no branch, build or deploy in between. One command moves both graphs to the new version, CI rejects the merge if the lockfile now holds a duplicate, and the deploy changes one JSON file and the HTML it is injected into.
The apps' JavaScript is byte-identical across the upgrade, because none of the design system was ever in it.
Impact
- Application bundles shrank by about 60%: the framework and design system are downloaded once per user and shared by all three apps instead of bundled three times.
- Version-pinned, immutable URLs stay cached across app deploys; a release that touches no dependency invalidates nothing.
- Exactly one element base class and one registration per tag, by construction — the precondition for sharing a web-component library across independently deployed apps at all.
- Design-system candidates are validated in the deployed test and beta apps by people who never run a build.