Shadow DOM Needed JavaScript to Exist. Now It Doesn't.
attachShadow() is a JavaScript-only API with no HTML serialization, so a server-rendered web component ships an empty host element until the client hydrates it. Declarative Shadow DOM attaches the real shadow root during HTML parsing — zero JS required.

You curl your own page. The one with the fancy web component — the accordion, the tabs, whatever you built to be reusable everywhere. You SSR'd it. You're proud of it. You grep the response for the text inside it.
It's not there.
What's there instead is a custom element with nothing inside it, and if you squint at the raw HTML you might spot a <template> tag nearby, doing nothing. The content — the styled, encapsulated, actually-rendered thing a user sees — doesn't exist yet. It shows up a beat later, after JavaScript runs, which on a slow connection or a busy main thread is long enough to see the host element sit empty, then pop.
Here's the part that takes people longer to accept than it should: this was never a bug in your SSR setup. Shadow DOM itself had no way to be server-rendered. Not "hard" — structurally absent.
The instinct, once you notice the flash, is to attack it like a loading-performance problem:
- Inline the critical CSS so at least the page doesn't flash unstyled. Doesn't help — the shadow tree's content isn't unstyled, it's absent. There's nothing to style yet.
- Defer less, preload the script earlier. Shaves milliseconds. The gap doesn't close, it shrinks — and on a throttled connection or a busy tab it's still visible.
- Skeleton-load it, show a placeholder shape until hydration. This works, and it's honestly the standard answer today — but notice what you just admitted: you're not server-rendering the component, you're server-rendering an apology for the component and rendering the real thing later, client-side, same as if you'd shipped no HTML at all.
All three treat the symptom. None of them explain why a server-rendered custom element can serialize its attributes and its light-DOM children into HTML, but not its shadow tree.
element.attachShadow({ mode: "open" }) is a method call. It runs in JavaScript, against a live DOM node, in a live document. There has never been an HTML syntax for "this element has a shadow root containing this markup" — the shadow tree simply doesn't exist as text you can send over the wire. A server can stringify your component's attributes and its regular children all day. The encapsulated part — the actual point of using Shadow DOM — has nowhere to go in that string.
So "server-rendered web component" quietly meant: server-rendered light DOM, client-attached shadow DOM, and a component that isn't really done rendering until JavaScript shows up and calls a method. For a design system trying to sell itself on being framework-agnostic and usable anywhere — including a no-JS environment, a crawler, or a slow device — that's a real gap, not a nitpick.
Declarative Shadow DOM closes the gap by teaching the HTML parser a new trick instead of teaching JavaScript a new one. Put a <template> with a shadowrootmode attribute as the first child of an element, and the parser attaches its contents as a real, live shadow root the instant it finishes reading the closing tag — before any <script> further down the page has run:
No script needed to make the second version render — document.querySelector("my-badge").shadowRoot is already non-null before your JavaScript has had a chance to run at all. If you do still want to progressively enhance it with a custom element class, that's fine: attach-shadow-if-not-already-attached is the standard guard, since a browser that already declaratively created the shadow root won't create a second one for you.
A couple of details worth knowing before you reach for it:
shadowrootmodeis"open"or"closed", mirroringattachShadow()'s mode option —"closed"still renders the content, it just meanselement.shadowRootreturnsnullto outside script, same as the imperative API.- Only the first matching
<template>counts. If a parent already got a declarative shadow root, a secondshadowrootmodetemplate in the same position is left alone as a plain, inert<template>element rather than throwing — a browsers-are-forgiving default, but not one to rely on if you're generating this HTML yourself. - Round-tripping it needs an extra flag. If you want to read a component's current shadow content back out as a string (say, to re-serialize it after a client-side update), add
shadowrootserializableto the template and callgetHTML({ serializableShadowRoots: true })— without it,outerHTMLand friends skip shadow content entirely, same as before.
Runs right in your browser — poke at it and watch the concept react live.
This isn't a "wait five years" feature. Declarative Shadow DOM is Baseline: Chrome and Edge since version 111 (with the full attribute set standardized by 124), Firefox since 123, Safari since 16.4. If your SSR framework or web component library doesn't emit shadowrootmode templates yet, that's a library gap, not a browser one — and it's worth filing an issue instead of writing another skeleton loader.
An empty custom element with a <template> next to it isn't broken HTML — it's HTML written for a browser that hasn't decided to help yet. Declarative Shadow DOM is the browser deciding to help: the parser does at read-time what a <script> tag used to have to do at run-time, and the gap between "the server sent HTML" and "the component actually rendered" gets to close for good.
Do you SSR web components today — and if so, are you shipping the skeleton-loader workaround or has your framework already picked this up? I'd like to know which libraries got there first.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.