Rendering Web Components on the server, without a single flash of unstyled content
Web Components that build their Shadow DOM in JavaScript inside connectedCallback often show a brief flash of unstyled content on first load. Declarative Shadow DOM instead describes the shadow root directly in the HTML via , so the browser constructs it during normal parsing, long before any JavaScript has even loaded.
Table of Contents
- 1. The FOUC problem with client-rendered Web Components
- 2. The syntax: in detail
- 3. Server-side rendering: producing the shadow root as an HTML string
- 4. How the streaming HTML parser builds the shadow root without JavaScript
- 5. Hydration: client-created Shadow DOM versus server-declared Shadow DOM
- 6. Style encapsulation: why adoptedStyleSheets does not work directly here
- 7. Fallback for older browsers: feature detection and polyfill
- 8. Declarative Shadow DOM in SSR frameworks and build pipelines
- 9. Common pitfalls and a comparison of rendering strategies
- 10. Summary
- 11. FAQ
1. The FOUC problem with client-rendered Web Components
Custom Elements traditionally build their Shadow DOM in JavaScript inside connectedCallback: the browser loads the base HTML first, then the JavaScript bundle, and only afterward does attachShadow() create the shadow root and fill it with markup and styles. In between, the user sees either nothing at all, unstructured light DOM content, or a brief flash of unstyled elements known as Flash of Unstyled Content (FOUC). For complex design-system components with many nested Custom Elements, that delay adds up and becomes visible.
Declarative Shadow DOM (DSD) solves exactly this problem by describing the shadow root directly as part of the HTML document instead of building it in JavaScript. The browser constructs the shadow root during normal HTML parsing, before any JavaScript has even loaded, parsed, or executed. That means a Web Component is already fully styled and structured on the very first render, regardless of how quickly the Custom Element module happens to load.
2. The syntax: in detail
The declaration itself is a template element with the shadowrootmode attribute, which must be the first child inside the Custom Element tag. The value open creates an open shadow root whose content stays reachable from outside via element.shadowRoot, exactly like the programmatic attachShadow({mode: 'open'}). Everything inside the template, meaning markup and style blocks, moves directly into the new shadow root as soon as the HTML parser hits the closing tag.
The value closed correspondingly creates a closed shadow root, where shadowRoot returns null from the outside. Importantly, the browser automatically removes the template element from the DOM after processing it and replaces it with the resulting shadow root, so the finished DOM tree no longer shows any template tag, only the regular shadowRoot node with its content.
<product-card>
<template shadowrootmode="open">
<style>
.card { border: 1px solid #e2e8f0; border-radius: 0.5rem; padding: 1rem; }
.title { font-weight: 600; }
</style>
<div class="card">
<p class="title"><slot name="title">Product name</slot></p>
<slot name="price">0.00 USD</slot>
</div>
</template>
<span slot="title">Mechanical Keyboard</span>
<span slot="price">89.00 USD</span>
</product-card>
3. Server-side rendering: producing the shadow root as an HTML string
On the server, Declarative Shadow DOM simply means that a render function for each Web Component produces an HTML string that already contains the template shadowrootmode="open" fragment. No special server runtime is needed, because this is plain, valid HTML: a simple template string or string concatenation in Node.js is enough to output the structure correctly.
It's essential that light DOM fallback content, for browsers without JavaScript or without the Custom Element definition, stays outside the template tag, while the actual component content lives inside the template. That way the server shows both the fully styled component for modern browsers and a sensible fallback for search-engine crawlers or older clients in a single pass.
// Node.js: simple SSR function for a product card
function renderProductCard({ title, price }) {
return `
<product-card>
<template shadowrootmode="open">
<style>.card { border: 1px solid #e2e8f0; padding: 1rem; }</style>
<div class="card">
<p class="title"><slot name="title">${title}</slot></p>
<slot name="price">${price}</slot>
</div>
</template>
<span slot="title">${title}</span>
<span slot="price">${price}</span>
</product-card>
`;
}
4. How the streaming HTML parser builds the shadow root without JavaScript
The decisive technical difference from any JavaScript-based solution lies in timing: the HTML parser interprets shadowrootmode during the normal, incremental streaming of the response, byte by byte, while further parts of the page are still being loaded from the server. There is no extra parsing step, no reflow from a later DOM rebuild, and no dependency on when a script happens to load.
That has a measurable effect on the Largest Contentful Paint metric: because the browser paints the finished, styled tree during the very first rendering pass, the usual second rendering cycle that would otherwise be required after JavaScript execution simply disappears. Even with JavaScript completely disabled, a component rendered with Declarative Shadow DOM remains fully visible and styled, something classic Web Components fundamentally cannot achieve.
5. Hydration: client-created Shadow DOM versus server-declared Shadow DOM
With classic client-side Shadow DOM, the connectedCallback of a Custom Element class typically creates the shadow root unconditionally with attachShadow(). If that same class now runs on a page that already brought a declarative shadow root along in the HTML, a second, unwanted call to attachShadow() would throw an error, because an element can only ever own a single shadow root.
The correct hydration strategy therefore checks this.shadowRoot first: if a shadow root already exists because the browser created it declaratively from the HTML, the code skips attachShadow() entirely and only attaches event listeners and reactive state to the existing structure. If shadowRoot is missing, for example because the component was inserted purely on the client, the original attachShadow() path kicks in as a fallback.
class ProductCard extends HTMLElement {
connectedCallback() {
// Already present declaratively? Then do not call attachShadow() again.
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = this.renderTemplate();
}
this.bindEvents();
}
bindEvents() {
this.shadowRoot.querySelector('button')
?.addEventListener('click', () => this.dispatchEvent(new Event('add-to-cart')));
}
}
6. Style encapsulation: why adoptedStyleSheets does not work directly here
A popular performance pattern for client-side Shadow DOM is adoptedStyleSheets: a CSSStyleSheet object created once gets shared across multiple shadow roots, so the browser never has to parse the same rules twice. This pattern cannot be serialized, however, because a CSSStyleSheet object has no HTML representation that the server could emit.
In practice, that means styles for Declarative Shadow DOM must ship as a classic style element inside the template, even if that costs a few more bytes per component instance on the server. Anyone who wants to switch back to shared stylesheets after hydration can do so afterward in JavaScript, by removing the inline style element and adopting a cached CSSStyleSheet object instead.
7. Fallback for older browsers: feature detection and polyfill
Browsers without support for Declarative Shadow DOM silently ignore the shadowrootmode attribute and treat the template element as usual, as an inert, unrendered element whose content stays invisible. Without a countermeasure, the entire component content disappears completely in such browsers, which is worse than not using Shadow DOM at all.
The common polyfill approach scans the document early, ideally before the first paint, using querySelectorAll for template[shadowrootmode], and manually calls attachShadow() plus moves the template content into the new shadow root for every match. Since this code itself requires JavaScript, the actual performance benefit stays limited to modern browsers, but at least functionality is preserved for everyone.
// Minimal polyfill: run early in <head>
document.querySelectorAll('template[shadowrootmode]').forEach((template) => {
const mode = template.getAttribute('shadowrootmode');
const host = template.parentNode;
const shadowRoot = host.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
});
8. Declarative Shadow DOM in SSR frameworks and build pipelines
Several Web-Component-focused frameworks and libraries now generate Declarative Shadow DOM automatically from a single component definition: the developer writes a class with a render() method, and the build tool or server renderer translates it into both the client-side attachShadow() call and the server-emitted template shadowrootmode block, without any duplication in the source code.
When integrating this into an existing build pipeline, it's crucial that the server renderer's output is written into the response raw, meaning without additional HTML escaping of the template contents, because otherwise the functional template tag would turn into a plain text string in the DOM. Many templating engines offer an explicit raw-HTML mode for exactly this purpose, which must be deliberately used for this one case.
9. Common pitfalls and a comparison of rendering strategies
A frequent mistake is placing more than one declarative template shadowrootmode as a direct child of the same element. The parser only processes the first template it finds and silently ignores all the rest, which leads to content that simply vanishes without any error message in the console. Just as important: if other markup already sits before the template tag as a child of the Custom Element, the declarative processing gets skipped entirely.
For test automation with tools that clone or serialize the DOM via innerHTML, it's also important to know that shadow roots don't appear in the innerHTML output by default. The newer getHTML({serializableShadowRoots: true}) method on elements makes that possible, which matters especially for snapshot tests or server-side re-rendering after state changes.
// Include shadow-root content in a snapshot test
const html = document.querySelector('product-card')
.getHTML({ serializableShadowRoots: true });
console.log(html); // contains the full shadow-root content
| Approach | Rendering timing | FOUC risk | Visible without JavaScript |
|---|---|---|---|
| Client-side attachShadow() | After JS execution in connectedCallback | High, visible delay | No |
| Declarative Shadow DOM | During HTML parsing | None | Yes |
| Polyfill via MutationObserver/querySelectorAll | Shortly after DOMContentLoaded | Small, brief delay | No |
| Light DOM only, no shadow | Immediate, but no encapsulation | None | Yes, but unencapsulated |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Declarative Shadow DOM: The Key Facts at a Glance
Core idea
The shadow root is described as a template shadowrootmode directly in the HTML instead of being created in JavaScript.
Biggest benefit
No more FOUC, the component is already fully styled and visible on the very first rendering pass.
Hydration rule
Always check whether this.shadowRoot already exists before calling attachShadow() to avoid errors.
Fallback needed
Browsers without support need an early-running polyfill, otherwise the content stays invisible.