Polyfilling Custom Elements in Legacy Browsers

Shipping the Web Components polyfill bundle unconditionally to every visitor is the most common way to make a component library slower on the browsers that need no help at all. The fix is to feature-detect the missing primitives, dynamically import the polyfill only on a miss, await WebComponentsReady, and account for the ES5 adapter when class output is transpiled.

The problem: an unconditional bundle regresses modern engines

The tempting first move is to load the all-in-one bundle at the top of the document so custom elements “just work” everywhere:

<!-- Anti-pattern: every visitor pays for this, native or not. -->
<script src="/vendor/webcomponents-bundle.js"></script>
<script type="module" src="/app/define-all.js"></script>

On a current Chromium, Firefox, or Safari build this is pure regression. The webcomponents-bundle.js file does not check whether the engine already implements the spec — it installs every shim eagerly. That means it replaces Element.prototype.attachShadow, patches the parser path used to upgrade custom elements, and registers a MutationObserver that walks the tree for the lifetime of the page, all on engines that already do every one of those things natively in C++. The visible symptoms are a needless 30–80 KB download, slower DOM-mutation hot paths because querySelector and appendChild are now routed through the ShadyDOM interception layer, and — most damaging — altered timing: native elements that should upgrade synchronously during parse now upgrade on a microtask instead, which can reorder code that assumed element.shadowRoot was available the instant the element was inserted.

Unconditional bundle versus feature-detected dynamic import Comparing two boot strategies: the unconditional bundle patches native engines and shifts upgrade timing, while feature-detected dynamic import loads nothing on native engines and only fetches missing shims on legacy ones. Unconditional bundle Feature-detected import() Native engine already has APIs Patch + bytes async upgrade slower DOM Legacy engine missing APIs Shims work whole bundle Native engine already has APIs Load nothing native timing kept Legacy engine missing APIs Loader: only gaps await ready Native visitors penalized Cost charged only where needed

Root-cause analysis

The bundle behaves this way by design, and the relevant spec behavior explains why patching a native engine is harmful rather than merely wasteful.

Per the WHATWG DOM Standard, a native engine upgrades a custom element as part of the tree-construction phase: when the parser encounters <my-widget> and a definition already exists, the element is upgraded synchronously before the next sibling is parsed. The custom-elements shim cannot hook the parser, so it approximates upgrade with a MutationObserver that fires on the microtask checkpoint after a batch of mutations. Installing that shim over a native engine therefore downgrades upgrade timing from synchronous-during-parse to asynchronous-after-parse. Any code path that read this.shadowRoot immediately after appendChild — valid on a native engine — now races the observer.

The bundle also monkey-patches attachShadow so that the ShadyDOM flattening model is used uniformly. On a native engine this replaces a real, browser-enforced boundary with a JavaScript-simulated one that re-routes traversal APIs, adding overhead to every subtree operation. The native boundary semantics it overrides are described in Shadow DOM Construction & Modes; the shim cannot match their fidelity, so applying it where it is not needed trades correctness for nothing.

The production-safe fix

Detect the two primitives that actually matter, import the polyfill only when one is absent, and await the readiness signal before registering any elements. Crucially, the loader (webcomponents-loader.js) does its own per-shim feature detection, so it fetches only the pieces a given legacy engine lacks — but the dynamic import() guard around it is what spares native engines from downloading anything.

// boot.js — the single entry the page loads as a module.
function nativeSupport() {
  return 'customElements' in window
    && 'attachShadow' in Element.prototype
    && 'getRootNode' in Element.prototype;
}

async function loadPolyfillIfNeeded() {
  if (nativeSupport()) {
    // Native engine: download and patch nothing.
    return;
  }

  // Legacy engine only: the loader fetches just the missing shims.
  await import('@webcomponents/webcomponentsjs/webcomponents-loader.js');

  // WebComponentsReady fires once all required shims are installed.
  if (window.WebComponents && window.WebComponents.ready) return; // already done
  await new Promise((resolve) => {
    window.addEventListener('WebComponentsReady', resolve, { once: true });
  });
}

await loadPolyfillIfNeeded();
// Only now is attachShadow / customElements.define guaranteed to exist.
await import('./elements/define-all.js');
document.documentElement.removeAttribute('data-wc-pending');

The ES5 adapter caveat

There is a second, subtler failure that appears only when build output is transpiled to ES5. A custom element class must call the native HTMLElement constructor, and the spec requires that constructor to be invoked with the new.target machinery that only real class syntax — or an explicit Reflect.construct — provides. When Babel or TypeScript downlevels a class to an ES5 function, the generated _super.call(this) cannot satisfy that requirement, and a native engine throws:

TypeError: Illegal constructor
  (or)
TypeError: Failed to construct 'HTMLElement': Please use the 'new' operator

The custom-elements-es5-adapter.js exists exactly for this case. It wraps the native HTMLElement (and the other built-in element constructors) so that an ES5-style call is internally re-issued through Reflect.construct(NativeHTMLElement, [], this.constructor), giving the transpiled subclass the correct new.target. It must load before any element is defined and only matters on a native engine running transpiled code:

// Load the adapter only when output is ES5 AND the engine is native.
const isTranspiledToEs5 = true; // set by your build, e.g. via a define
if (isTranspiledToEs5 && nativeSupport()) {
  await import('@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js');
}

The cleanest way to sidestep the adapter entirely is to ship native class syntax to engines that support custom elements — every engine that has customElements also supports ES2015 classes — and reserve the ES5 path for the genuinely old tier, where the full loader is loading anyway.

Verification

Confirm both halves of the fix with direct observation rather than assumption.

On a current Chromium build, open DevTools and reload. In the Network panel, filter for webcomponents: the polyfill chunk must be absent. In the Console, the following must hold, proving the native path was taken and timing was not altered:

console.log('customElements' in window);                 // true (native)
console.log(Element.prototype.attachShadow.toString());   // "[native code]"

// Synchronous upgrade survives — shadowRoot exists immediately after insertion.
customElements.define('verify-el', class extends HTMLElement {
  constructor() { super(); this.attachShadow({ mode: 'open' }); }
});
const el = document.createElement('verify-el');
document.body.append(el);
console.log(el.shadowRoot !== null);                      // true, no await needed

To exercise the legacy path without an old browser, temporarily delete the primitives before boot.js runs (in a throwaway test page, never in production):

delete window.customElements;            // force the "needs polyfill" branch
// Reload boot.js; the import() should now fire and WebComponentsReady dispatch.
window.addEventListener('WebComponentsReady', () =>
  console.log('polyfill installed, ready'));

For the ES5 adapter, build with an ES5 target, omit the adapter, and confirm the Illegal constructor throw appears on a native engine; then add the adapter import and confirm the element upgrades cleanly. That before/after pair is the proof the adapter is doing its job.

When to use and when to avoid

Situation Decision
Audience is current evergreen browsers only No polyfill — feature detection short-circuits to the native path
Must support an old locked-down enterprise build lacking attachShadow Load webcomponents-loader.js behind the detection guard
Build output is transpiled to ES5 and runs on native engines Add custom-elements-es5-adapter.js, or better, ship native class syntax instead
You control the runtime and know every visitor lacks support The unconditional bundle is acceptable — detection would always miss anyway
No JavaScript will run at all A polyfill cannot help; rely on light-DOM fallback instead
Need ::part/::slotted pixel parity on the legacy tier Avoid — ShadyCSS only approximates these; treat polyfilled output as degraded

The governing principle is that a polyfill is a cost paid by the visitor, so it must be charged only to visitors who benefit. Feature detection plus dynamic import() makes that charge precise; the ES5 adapter is a narrow correction layered on top only when transpilation forces it.