Sergi Jajanidze

Senior Frontend Engineer

High-traffic consumer public site

Design system runtime integration

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 harder problem underneath it. Every one of those apps is built from the same web-component design system: 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 registry that is not copy-safe. Custom elements are registered once per window; a second define() of an existing tag throws, and where tags don't collide you get two base classes — instanceof fails across the boundary and updates schedule on two independent queues. Independent deploys are worth little 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.

  ┌───────────┐      ┌───────────┐      ┌───────────┐
  │   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. 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 cost 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 177 entries don't point at a CDN at all. Two are the shell's own federation containers; one is a trailing-slash prefix mapping that serves the legacy web-component polyfills 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 leaves 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. A 177-entry map pasted into index.html is a merge conflict waiting to happen and a second place versions can drift. 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, registering the JSON as a file dependency so a version bump invalidates the HTML in watch mode:

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);
    },
  );
});

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 why the map is assembled centrally at build time rather than contributed to by each of the three apps.

A dev-only runtime map, so a release candidate can be tested without a deploy. Pinning to immutable URLs makes upgrades safe and slow to validate: seeing a design-system candidate in the real application would otherwise mean bump, rebuild, deploy — for every round of feedback. In non-production builds the plugin 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.indexOf('@ds/') !== 0) 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. The flag itself is set from localStorage or a query parameter, and a floating button flips it and reloads.

Import maps govern JavaScript module specifiers and nothing else, so the design system's CSS is a plain <link> no map can repoint. Those links are parsed after the head script runs, so the toggle attaches a MutationObserver to head, rewrites their directory as they appear, and disconnects on DOMContentLoaded — ten visible lines, and the reason a candidate is never tested with 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 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;

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 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 — 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.