Sergi Jajanidze

Senior Frontend Engineer

High-traffic consumer public site

Serving a design system from a CDN at runtime

Role
Lead engineer — designed and built the dependency-resolution layer
Stack
Webpack 5 externals · Native import maps · Native ESM · Lit 3 · Module Federation
Scope
177 bare specifiers in one map — ~106 design-system packages, the framework runtime and its directives, and the federation entry points — resolved by the browser across three independently deployed applications

Splitting the site into three independently deployed applications solved the deploy coupling and created a worse problem underneath it. Every one of those apps is built from the same web-component design system: at the time, ~106 packages plus the framework runtime they all extend. Bundled the ordinary way, that is three copies of the design system, three copies of the element base class, and three copies of a global registry that is not global-safe. Custom elements are registered once per window; a second define() of a tag that already exists throws, and where the tags happen not to collide you get two base classes instead — instanceof fails across the boundary and updates schedule on two independent queues. Independent deploys are worthless if the thing being deployed can't share a runtime.

So the dependencies stopped being build inputs. Every shared package is declared as a webpack external that compiles to an untouched bare specifier, and a build-time-injected import map tells the browser that @ds/button means one specific immutable URL. The bundler stops resolving; the platform resolves. Resolution moves from build time to the document — what follows is how that is wired, and what it costs.

  ┌───────────┐      ┌───────────┐      ┌───────────┐
  │   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
          "@ds/button" : "<cdn>/@ds/button/0.2.6/index.js"   ← version-pinned,
          "lit"        : "<cdn>/@vendor/lit/3.1.0/index.js"     immutable
          "common"     : "/common-entry.mjs"                 ← the same map also
        </script>                                              resolves the
                           │                                   federation entries
                           ▼
              browser module loader — one resolution,
              one instance, shared by all three apps

Decisions worth defending

The map generates the externals, not the other way around. The classic failure of hand-written externals is a specifier that survives the build with nothing to resolve it: the bundle ships, the browser hits import '@ds/tooltip', and the page dies on Failed to resolve module specifier — at runtime, on the route that happens to use it, in production. The fix is to make the two lists the same list. The import map is a checked-in JSON file, and the webpack config reads it and derives externals from its keys:

// webpack.config.js — one source of truth
const importMap = require('./import-map.json');
const libs = importMap.imports || {};

const externals = Object.keys(libs).reduce((acc, key) => {
  acc[key] = `import ${key}`;    // webpack's `import` external type
  return acc;
}, {});

module.exports = {
  output:      { module: true, filename: '[name].[chunkhash:8].js' },
  experiments: { outputModule: true },
  externals,
};

You cannot externalize a package without giving it a resolution rule, and you cannot add a resolution rule that isn't externalized. The failure mode isn't handled — it's unrepresentable.

The import external type resolves each specifier through a real runtime import() rather than a webpack runtime lookup, which turns every consuming module into an async module. That's a genuine cost and it was acceptable here for a specific reason: the entry is already a module script and every page already arrives through a dynamic import, so the asynchrony was already in the graph. It requires experiments.outputModule — externals like this are only coherent when the output is itself an ES module.

The same map resolves the federation entry points. Three of the map's entries don't point at a CDN at all. Two of them are the shell's own federation containers, and one is a trailing-slash prefix mapping that keeps the legacy web component polyfills served from the app's own origin:

{
  "imports": {
    "@webcomponents/": "/node_modules/@webcomponents/",   // prefix mapping: whole subtree
    "ui-elements":     "/ui-entry.mjs",
    "common":          "/common-entry.mjs"
  }
}
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    retail:        `import ${RETAIL_URL}/retail-entry.mjs`,
    business:      `import ${BUSINESS_URL}/business-entry.mjs`,
    'ui-elements': 'import ui-elements',   // ← resolved by the map, not by the build
    'common':      'import common',
  },
}),

Federation's remote locations are normally compiled in. Routing two of them through the map makes where a remote lives a deployment-time fact rather than a build-time constant, and it means there is exactly one mechanism to reason about when something fails to load. It also reads correctly: a shared container and a shared CDN package are the same kind of thing — a module this app depends on but does not own.

Injection is a plugin, because a template edit rots. A 177-entry map pasted into index.html is a merge conflict waiting to happen and a second place versions can drift. Instead a small plugin resolves the map — from a file, a literal, or the two merged — and writes it into the document through html-webpack-plugin's beforeEmit hook:

compiler.hooks.compilation.tap('ImportMapPlugin', (compilation) => {
  HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
    'ImportMapPlugin',
    async (data, cb) => {
      const map = await this.resolveImportMap();
      const tag = `<script type="importmap">${JSON.stringify(map, null, 2)}</script>`;
      data.html = data.html.replace('<head>', `<head>\n${tag}`);   // head-prepend
      cb(null, data);
    },
  );
});

// an edit to the JSON has to invalidate the HTML in watch mode
compiler.hooks.afterCompile.tap('ImportMapPlugin', (compilation) => {
  compilation.fileDependencies.add(this.mapPath);
});

Head-prepend is not a style choice. An import map only governs resolutions that happen after it is parsed, so it has to land ahead of the module script the build injects — and for most of this system's life the platform allowed exactly one map per document, browsers only beginning to accept more than one in 2025. One map, one place, emitted first. That constraint is the reason the map is assembled centrally at build time rather than contributed to by each of the three apps.

A dev-only runtime map, so QA can test a release candidate without a deploy. Pinning to immutable URLs makes upgrades safe and makes them slow to validate: to see a design-system release candidate in the real application you would bump the map, rebuild, and deploy — for every round of feedback. In non-production builds the plugin therefore emits something else. Instead of a static map it serializes a function to source and emits it as an inline IIFE seeded with the base map. The function rewrites the version segment of every design-system URL to the next channel when a flag is set, then constructs the import map element itself:

// Serialized with String(fn) and invoked with the base map. It must be entirely
// self-contained — toString() captures this function's own source and nothing else:
// no imports, no outer-scope references, no build-time constants.
module.exports = function devToggle(baseMap) {
  const useNext = localStorage.getItem('ds-next') === '1';

  if (useNext) {
    for (const key of Object.keys(baseMap.imports)) {
      if (!key.startsWith('@ds/')) continue;
      const url = swapVersionSegment(baseMap.imports[key], 'next');
      baseMap.imports[key] = url;

      // Some consumers import the deep path ('@ds/loader/loader.js') for which
      // nothing is published — only index.js. Alias it so the external resolves.
      baseMap.imports[`${key}/${key.split('/').pop()}.js`] = url;
    }
  }

  const s = document.createElement('script');
  s.type = 'importmap';
  s.textContent = JSON.stringify(baseMap);
  document.head.appendChild(s);   // still ahead of the deferred module scripts
};

The self-containment constraint is the interesting part of that code. Serializing a function to ship it into the browser means the closure doesn't come with it — every helper has to be inlined or defined inside, and there is no bundler to catch the mistake. In exchange the toggle needs no build integration beyond one boolean, and it ships as part of the same tag that would otherwise hold the static map, so there is no ordering question about which one wins.

Stylesheets are the honest gap. Import maps govern JavaScript module specifiers and nothing else, so the design system's CSS is a plain <link> that no map can repoint. Those links are parsed after the head script runs, so the toggle attaches a MutationObserver to head and rewrites their directory as they appear, disconnecting on DOMContentLoaded. It is a workaround and it reads like one; I'd rather have it visible in ten lines than have QA silently testing new components against old tokens.

Two resolution graphs, one version number. Externalizing a package doesn't remove it from node_modules — the editor, the linter and the unit tests still resolve it there at build time, while the browser resolves it through the map at runtime. Two graphs describing the same dependency is exactly the shape that drifts, and drift here is invisible: tests pass against one version while users get another. So bumping is one command and one script that writes both:

npm outdated --long > versions.txt && node scripts/update-versions.js
// the wanted version goes to both places, or to neither
importMap.imports[pkg] = url.replace(/\/(\d+\.\d+\.\d+)\//, `/${wanted}/`);
packageJson.dependencies[pkg] = wanted;

And because a design system is a tree of packages that depend on each other, a resolved lockfile can quietly contain two versions of the same element package — which puts the duplicate-registration problem right back, at install time instead of build time. A CI step parses the lockfile and fails the build on any package resolved to more than one version. It is thirty lines and it is the single highest-value check in the repo.

The release loop it created

The mechanics above collapse into a loop that no longer involves the application teams for most of its length. The design-system team publishes a candidate to the next channel. QA flips a toggle on the real production application — production build, production data, candidate components — and signs off or doesn't, with no branch, no build and no deploy anywhere in that round trip. On sign-off, npm outdated and one script move both graphs to the new version, CI refuses the merge if the lockfile now contains a duplicate, and the resulting deploy changes a JSON file and the HTML it was injected into.

The applications' JavaScript is byte-identical across that upgrade, because none of the design system was ever in it.

Impact

  • The framework runtime and ~106 design-system packages are downloaded once per user and shared by all three applications, rather than bundled three times.
  • Because every URL is version-pinned and immutable, that code stays cached across application deploys — the common case, a product release that touches no dependency, invalidates nothing.
  • Exactly one instance of the element base class and one custom-element registration per tag, by construction rather than by negotiation. This is the precondition that makes a shared web-component library across three independently deployed apps possible at all.
  • A design-system upgrade ships as a version bump in one file, with the applications' JavaScript unchanged and no rebuild of the remotes.
  • Design-system candidates are validated against the real production application before release, by people who don't need to run a build to do it.

What I'd change

Nothing verifies that the map resolves. Every value in it is a string, and a wrong version segment is not a build error — it's a 404 on a bare-specifier import, surfacing as a blank route in production. The pieces that make this checkable already exist: a CI step that issues a HEAD against all 177 URLs and asserts each version segment matches the lockfile would turn the entire class of failure into a red build. It's an afternoon of work and it's the first thing I'd add.

The map has no integrity metadata. Import maps support a top-level integrity key, and this is close to the ideal case for it: every entry is immutable and version-pinned, so the same script that rewrites version segments could fetch each artifact and emit its hash. For a first-party CDN loading executable code into a banking origin, “we control that host” is a weaker argument than a hash that costs one build step.

The map removes a resolution step but adds a discovery one. The browser can't request a single CDN module until it has parsed the entry chunk and reached the import — the map says where things are, not that they will be needed. Fonts and the design system's CSS are preloaded; its JavaScript isn't. Emitting <link rel="modulepreload"> for the packages on the first-paint path, from the same plugin that already writes the map and therefore already knows every URL, removes a round trip from every cold load.

The guarantee is weaker than it looks at the edges. “Version-pinned and immutable” describes most of the map, not all of it: a handful of legacy vendor entries carry no version segment at all, which makes them mutable URLs cached under rules the rest of the system is designed to avoid. Keeping the map as a plain checked-in file is what makes that visible in review — but visible isn't enforced. The complement to the duplicate guard already in CI is a schema check over the map itself: every value version-pinned, every key present in the lockfile, no entry without an owner.