Avoiding Hydration Mismatches in Custom Elements
A custom element written for client-only rendering will crash the moment it is server-rendered, because its constructor assumes it owns the job of creating the shadow root. When the parser has already attached a declarative shadow root, a constructor that calls attachShadow() unconditionally throws NotSupportedError, and every server-rendered instance dies on upgrade. The fix is to detect and adopt the existing root instead of recreating it.
This deep-dive belongs to Server-Side Rendering & Hydration and walks through the exact upgrade-time state that causes the mismatch, then the adopt-or-create pattern that makes one class serve both the server-rendered and client-only tiers.
The minimal reproducible example
This component is correct for a client-only application and entirely broken for a server-rendered one.
// BROKEN against server-rendered markup.
class DsToggle extends HTMLElement {
#root;
constructor() {
super();
// Throws NotSupportedError when a declarative root already exists.
this.#root = this.attachShadow({ mode: 'open' });
this.#root.innerHTML = `
<style>:host { display: inline-block; }</style>
<button part="switch" aria-pressed="false">Off</button>`;
this.#root.querySelector('button')
.addEventListener('click', () => this.#flip());
}
#flip() { /* toggle logic */ }
}
customElements.define('ds-toggle', DsToggle);
Server-render the same element — the page ships this markup — and load it:
<ds-toggle>
<template shadowrootmode="open">
<style>:host { display: inline-block; }</style>
<button part="switch" aria-pressed="false">Off</button>
</template>
</ds-toggle>
Uncaught DOMException: Failed to execute 'attachShadow' on 'Element':
Shadow root cannot be created on a host which already hosts a shadow tree.
The element never upgrades, so the button never gets its click handler, and the page is interactive only in appearance.
Root-cause analysis
Custom element upgrade runs the constructor after the parser has built the element’s subtree. When the markup contained a <template shadowrootmode>, the parser executed the “attach a shadow root” steps during tree construction — long before the element’s definition was even registered. By the time the upgrade calls the constructor, the host already hosts a shadow tree, and this.shadowRoot (for an open root) is populated.
The WHATWG DOM Standard’s attachShadow() algorithm has an explicit guard: if the element already hosts a shadow root that is not a declaratively-attached one awaiting reuse, it throws NotSupportedError. A second attachShadow() call is therefore never valid against an element that the parser has already given a root. The same is true whether the existing root is open or closed: for a closed declarative root, this.shadowRoot reads null, but the element’s ElementInternals.shadowRoot (obtained via attachInternals()) exposes it, and attachShadow() still throws.
This is the inverse of the usual mental model. Engineers expect the constructor to create state; under hydration the constructor must discover state that the parser already created. The distinguishing signal is simply whether a shadow root is present before the constructor does anything:
this.shadowRootis non-null → an open declarative root exists; adopt it.this.shadowRootis null butthis.internals.shadowRootis non-null → a closed declarative root exists; adopt that.- both null → no declarative root; this is a client-only instantiation; create one.
There is one more timing subtlety that distinguishes hydration from a fresh client render: when the element is parser-created (which every server-rendered instance is), the children and the declarative root exist before the constructor runs, but for a parser-created element the constructor is the upgrade and the element is not yet connected. connectedCallback then fires once, after the constructor, when the parser inserts the element into the document. For a script-created client instance (document.createElement('ds-toggle')), the constructor runs immediately with no children and no root, and connectedCallback fires only when the script appends it. The adopt-or-create constructor handles both because it keys purely off the presence of a root, not off how the element came to exist. The reason the closed-root branch reads from ElementInternals rather than this.shadowRoot is the encapsulation contract covered in Open vs Closed Shadow DOM Tradeoffs: a closed root is intentionally hidden from this.shadowRoot, but the element that owns it can still reach it through the internals object it received from attachInternals().
Note also that this is genuinely not the framework notion of a hydration mismatch. There is no second render to compare against, no checksum, and no warning the platform can emit — the server-produced DOM is the only DOM. The single way to “mismatch” is for the upgrade to call attachShadow() a second time, which the DOM Standard answers with a hard NotSupportedError rather than a recoverable warning. Treating the constructor as a discovery step rather than a construction step is therefore the whole of the fix.
The production-safe fix
Coalesce: read the existing root first, fall back to creating one only when none exists. Then guard initialization so it runs exactly once, and skip re-rendering when the server already populated the tree.
class DsToggle extends HTMLElement {
static observedAttributes = ['pressed'];
#internals;
#root;
#initialized = false;
#controller = new AbortController();
constructor() {
super();
this.#internals = this.attachInternals();
// Adopt an already-attached declarative root (open OR closed);
// create one only when none was server-rendered.
this.#root =
this.shadowRoot ??
this.#internals.shadowRoot ??
this.attachShadow({ mode: 'open', serializable: true });
}
connectedCallback() {
if (this.#initialized) return; // adopt-once; survives re-insertion
this.#initialized = true;
// Critical path: wire behavior. The markup may already exist (SSR)
// or may need rendering (client-only) — render only if empty.
if (this.#root.childElementCount === 0) {
this.#root.innerHTML = this.#template();
}
const button = this.#root.querySelector('button');
button.addEventListener('click', () => this.#flip(), {
signal: this.#controller.signal,
});
// Non-critical work deferred off the upgrade/paint path.
queueMicrotask(() => this.#syncFromAttributes());
}
disconnectedCallback() {
this.#controller.abort(); // tear down listeners on removal
}
#template() {
return `
<style>:host { display: inline-block; }</style>
<button part="switch" aria-pressed="false">Off</button>`;
}
#syncFromAttributes() {
const pressed = this.hasAttribute('pressed');
this.#root.querySelector('button')
?.setAttribute('aria-pressed', String(pressed));
}
#flip() {
const button = this.#root.querySelector('button');
const next = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(next));
button.textContent = next ? 'On' : 'Off';
this.toggleAttribute('pressed', next);
}
}
customElements.define('ds-toggle', DsToggle);
Three properties make this hydration-safe. The constructor never assumes ownership of the root — it adopts whatever the parser produced. Rendering is conditional: a server-populated root has children, so the client never overwrites server markup (which would otherwise blank the styles for a frame). And listener wiring is idempotent and torn down via AbortController, so an element that is moved in the DOM and re-connected does not stack duplicate handlers.
Verification
Test both tiers against the single class. The server-rendered tier is the one that previously threw.
// TIER 1 — server-rendered: declarative root already present before upgrade.
document.body.innerHTML = `
<ds-toggle>
<template shadowrootmode="open">
<style>:host { display: inline-block; }</style>
<button part="switch" aria-pressed="false">Off</button>
</template>
</ds-toggle>`;
await customElements.whenDefined('ds-toggle');
const ssr = document.querySelector('ds-toggle');
console.assert(ssr.shadowRoot !== null, 'adopted the declarative root, no throw');
ssr.shadowRoot.querySelector('button').click();
console.assert(
ssr.shadowRoot.querySelector('button').textContent === 'On',
'behavior wired to the server-rendered button'
);
// TIER 2 — client-only: no declarative root, constructor creates one.
const csr = document.createElement('ds-toggle');
document.body.append(csr);
console.assert(csr.shadowRoot.childElementCount > 0, 'rendered on the client');
If the broken version were still in place, tier 1 would log an uncaught NotSupportedError and ds-toggle would never appear in the registry’s upgraded set. The passing tier-1 assertion that the click reaches the server-rendered button is the proof that adoption — not re-creation — occurred. In DevTools, confirm there is exactly one #shadow-root (open) under the host and no orphaned <template> left behind.
To catch the regression automatically rather than by inspection, assert that the constructor did not throw and that the adopted button is the same node the server emitted, not a freshly rendered replacement:
// Regression guard: prove no re-creation happened.
const buttonBeforeUpgrade = document
.querySelector('ds-toggle template') // null after upgrade — captured earlier in a real test
;
// Practical signal in a test runner: identity of the rendered button must be stable
// across an attribute-driven update, and the root must not be re-attached.
const t = document.querySelector('ds-toggle');
const b1 = t.shadowRoot.querySelector('button');
t.toggleAttribute('pressed', true);
const b2 = t.shadowRoot.querySelector('button');
console.assert(b1 === b2, 'adopted node is reused, not replaced — no re-render');
In a CI pipeline, wrap the upgrade in a listener for the global error event and fail the test if any NotSupportedError surfaces during customElements.whenDefined(). Because the throw happens inside the upgrade reaction rather than in your own call stack, a plain try/catch around the markup assignment will not see it — the browser reports it as an unhandled exception on the window, so the global listener is the only reliable interception point.
When to use / when to avoid
| Branch at upgrade | Condition | Action |
|---|---|---|
| Adopt open root | this.shadowRoot !== null |
Reuse it; do not call attachShadow(). |
| Adopt closed root | this.shadowRoot === null && this.internals.shadowRoot !== null |
Reuse the internals root; do not call attachShadow(). |
| Create root | both are null |
Client-only instantiation — attachShadow({ serializable: true }). |
| Render markup | root.childElementCount === 0 |
Server did not populate it; render the template. |
| Skip render | root.childElementCount > 0 |
Server already painted it; leave it untouched. |
| Defer | non-critical sync / measurement work | queueMicrotask or requestIdleCallback, off the upgrade path. |
Avoid the unconditional attachShadow() constructor entirely once any consumer might server-render the component — there is no scenario in which re-creating an existing root is correct. Avoid re-rendering an adopted root’s innerHTML on hydration; it discards the server output and reintroduces the flash that SSR exists to prevent. And avoid doing measurement or network work synchronously in connectedCallback during hydration — defer it so the adopted, already-painted tree stays interactive immediately.
Related
- Server-Side Rendering & Hydration — the parent topic covering the full render-then-hydrate sequence.
- Rendering declarative Shadow DOM on the server — producing the markup whose root this page adopts.
- Distribution, Testing & Tooling — the section governing how components ship and run across environments.
- Shadow DOM Construction & Modes — the
attachShadow()semantics and theNotSupportedErrorguard. - Open vs Closed Shadow DOM Tradeoffs — why a closed declarative root is reached through
ElementInternalsrather thanthis.shadowRoot.