loke.dev
Header image for The Global Registry Is a Namespace Trap

The Global Registry Is a Namespace Trap

Solve the micro-frontend versioning nightmare by moving beyond the global window object and mastering the architectural isolation of scoped custom element registries.

· 7 min read

We’ve been told for a decade that the beauty of Web Components lies in their interoperability. "Write once, run anywhere" was the promise. We were led to believe that customElements.define('my-element', MyElement) was the ultimate realization of a standardized component model. It isn't. In fact, for any developer working on a large-scale micro-frontend (MFE) architecture, the global CustomElementRegistry is a trap. It's a single, global namespace that creates a brittle environment where different teams are constantly one version-bump away from breaking the entire production site.

If you’ve ever seen the error DOMException: Registration failed for type 'my-button'. A type with that name is already registered, you’ve felt the jaws of this trap close.

The Global Namespace is a Scaling Poison

The standard way to register a web component is through window.customElements. This is a global registry. It functions exactly like a global variable, and we spent the last twenty years learning why global variables are a disaster for maintainability.

Imagine a scenario. You have a large enterprise dashboard. Team A manages the User Profile section and uses a component library called UI-Kit version 1.0. Team B manages the Billing section and needs a new feature found in UI-Kit version 2.0.

In a standard React or Vue world, this is a non-issue; the bundler just includes both versions in their respective chunks. But with Web Components registered globally, the first team to call customElements.define wins. The second team’s registration fails. You are now stuck in "Version Lock," where every team in the organization must coordinate their upgrades simultaneously. That isn't agility; it’s a bottleneck.

The Standard (Failing) Approach

Here is what most of us have been doing:

// team-a/button-v1.js
class ButtonV1 extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button style="color: blue;">V1 Button</button>`;
  }
}
customElements.define('app-button', ButtonV1);

// team-b/button-v2.js
class ButtonV2 extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button style="color: red;">V2 Button (with bug fixes!)</button>`;
  }
}

// This will throw: "DOMException: Registration failed..."
customElements.define('app-button', ButtonV2); 

The browser doesn't care about your release schedule. It sees a name collision and halts.

The Prefixing "Solution" is a Maintenance Debt Factory

The most common workaround is prefixing or suffixing tag names. We start seeing things like <app-button-v1> and <app-button-v2>.

I’ve seen teams try to automate this with build scripts that append a hash to the tag name during the CI/CD process. While this "solves" the collision, it creates a nightmare for CSS and testing. If your tag name is <ui-card-8f2k9>, how are you writing your global brand stylesheets? How do your automated E2E tests target elements? You end up with a codebase that looks like it was mangled by an obfuscator, and you lose the semantic clarity that made Web Components attractive in the first place.

Enter the Scoped Custom Element Registry

The real solution—and the architectural shift we need to embrace—is the Scoped Custom Element Registry. This is a proposal (and a polyfillable reality) that allows you to create a local registry and associate it with a specific ShadowRoot.

Instead of declaring a component globally on the window, you define it within a specific scope. This allows the same tag name, <ui-button>, to point to two completely different classes in two different parts of the DOM.

How Scoping Actually Works

To use scoped registries, you don't call customElements.define. Instead, you instantiate a new CustomElementRegistry and pass it as an option when attaching a shadow root.

*Note: Since this is still moving through the standards process, you'll likely need the Scoped Custom Element Registry polyfill for production support.*

// Create a private registry for Team A
const teamARegistry = new CustomElementRegistry();
teamARegistry.define('ui-button', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `<button>Team A (V1)</button>`;
  }
});

// Create a private registry for Team B
const teamBRegistry = new CustomElementRegistry();
teamBRegistry.define('ui-button', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `<button>Team B (V2)</button>`;
  }
});

// Now, we apply these registries to specific containers
const containerA = document.getElementById('team-a-zone');
const shadowA = containerA.attachShadow({ mode: 'open', registry: teamARegistry });
shadowA.innerHTML = `<ui-button></ui-button>`;

const containerB = document.getElementById('team-b-zone');
const shadowB = containerB.attachShadow({ mode: 'open', registry: teamBRegistry });
shadowB.innerHTML = `<ui-button></ui-button>`;

With this pattern, the "Namespace Trap" is avoided entirely. containerA and containerB coexist on the same page, using the same tag name, but rendering different logic and styles. This is the level of isolation required for true micro-frontend independence.

Re-thinking Component Discovery

One thing I realized while implementing scoped registries is that it changes how components "find" each other. In a global world, a parent component just puts <child-element> in its template and assumes the browser knows what that is.

In a scoped world, the parent is responsible for its own dependencies. If you are building a Dashboard component that uses a UserAvatar component, the Dashboard needs to register UserAvatar within its own scope.

The "Registry Per Micro-Frontend" Pattern

A clean way to handle this in an MFE architecture is to give each micro-frontend its own registry instance.

// mfe-loader.js
async function loadMicroFrontend(name, entryPoint, container) {
  const module = await import(entryPoint);
  const mfeRegistry = new CustomElementRegistry();
  
  // The MFE exports a function to register its internal components
  // into the provided scoped registry.
  module.registerComponents(mfeRegistry);

  const shadow = container.attachShadow({ mode: 'open', registry: mfeRegistry });
  
  // The MFE's root element is now safely scoped
  shadow.innerHTML = `<${module.rootTag}></${module.rootTag}>`;
}

This gives each team a "sandbox." They can upgrade their internal libraries, change versions of their design systems, and experiment with new components without ever fearing that they will knock out the navigation bar or the footer managed by other teams.

The Shadow DOM Requirement

There is a catch that catches people off guard: Scoped registries require Shadow DOM.

You cannot use a scoped registry on the "light DOM" (the main document). This is a deliberate design choice. The main document *is* the global scope. If you want isolation, you have to use the boundary provided by Shadow DOM.

I've encountered developers who avoid Shadow DOM because of CSS encapsulation (they want their global styles to leak in). But if you are building MFEs, CSS encapsulation is actually your best friend. If you really need shared styles, use Constructable Stylesheets to inject them into your scoped shadow roots.

const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`button { border-radius: 4px; padding: 10px; }`);

// When creating the shadow root
const shadow = host.attachShadow({ mode: 'open', registry: myRegistry });
shadow.adoptedStyleSheets = [sharedStyles];

Challenges and Edge Cases

Moving to scoped registries isn't just a "flip the switch" situation. There are trade-offs and technical hurdles I've navigated that you should be aware of.

1. Element Discovery and instanceof

When you use scoped registries, document.createElement('ui-button') will not return your custom element if it was registered in a scope. It will return an HTMLUnknownElement (or a generic HTMLElement). You must use shadowRoot.createElement() or simply use innerHTML/templates within that shadow root. This changes how some utility libraries or testing frameworks might interact with your DOM.

2. The Polyfill Performance

The scoped-custom-element-registry polyfill is incredibly clever, but it's not free. It works by intercepting attachShadow and patching how the browser resolves tag names. In extremely DOM-heavy applications (thousands of custom elements being instantiated per second), you might see a slight dip in registration performance. In my experience, for standard MFE shells, this is negligible compared to the architectural safety it provides.

3. Third-Party Libraries

Many third-party Web Component libraries are written with the assumption of a global registry. They might have a register() function that internally calls customElements.define(). To make these work with scoped registries, you may need to wrap them or petition the authors to accept a registry as an argument:

// Ideally, libraries should do this:
export function register(registry = window.customElements) {
  registry.define('awesome-widget', AwesomeWidget);
}

Why This Matters for the Future of the Web

We are seeing a shift in how web applications are composed. We are moving away from monolithic bundles toward federated, independent units of deployment. The global customElements registry is a vestige of a simpler time when we assumed one page = one app = one team.

By mastering scoped registries, you are essentially "future-proofing" your architecture. You are moving the responsibility of component definition from the global environment to the local module. This is exactly how JavaScript modules themselves evolved—moving away from window.MyLibrary to localized import statements.

If you are starting a new Micro-frontend project today, don't fall into the namespace trap. Start with a scoped mindset. Use the polyfill, isolate your teams, and let the tag names be as generic as they want to be. Your future self, trying to debug a version collision at 2:00 PM on a Friday, will thank you.