Native Web Components Instead of Hydration: What customElements.define() Actually Buys You
The hydration 'uncanny valley' is the interval where a framework-rendered page looks interactive but isn't. Native custom elements skip the framework entirely for isolated interactive widgets - but a custom element with heavy connectedCallback logic is not automatically zero-cost, and this article is precise about which part of the cost that architecture removes.
Disclosure: We implement these architectures for clients and use them on our own infrastructure.
The uncanny valley, named precisely
A server-rendered React or Vue page has an interval - between first paint and hydration completing - where the page looks fully interactive and isn’t. Buttons render. They don’t respond. A user who taps during that window gets nothing, or gets a queued interaction that fires late and confusingly. The industry term for this, borrowed from robotics and animation, is the uncanny valley: the page resembles a working interface closely enough that the gap between appearance and function reads as broken rather than loading.
This is not a framework bug. It’s the direct consequence of the hydration model: the framework must walk the rendered DOM and attach event listeners before anything responds, and that walk takes time proportional to the tree it’s walking - the entire page, in a traditional SPA, even when only one widget on it needs interactivity.
Native custom elements sidestep the model that produces the uncanny valley, because there’s no tree to walk and no framework runtime deciding when “interactive” arrives. An element either has its behavior attached or it doesn’t, per element, at parse time.
What a custom element actually costs
Precision matters here, because the wrong claim is easy to make and easy to catch. A custom element does not automatically mean zero JavaScript execution. connectedCallback() runs real code, on the main thread, like any other function. What a custom element does guarantee, that a framework component does not:
- No framework runtime shipped or parsed for elements that use only built-in behavior
- No virtual DOM diff cycle on update - you mutate the real DOM directly
- No two-pass render (server HTML, then client re-render to attach behavior) - the element’s behavior attaches once, when it’s defined and connected
- No bundler-driven code splitting decisions about what ships together - each element is its own unit by construction
Total Blocking Time reaching zero is a consequence of what you put in connectedCallback, not an automatic property of the architecture. A custom element with an expensive synchronous initialization routine blocks the main thread exactly as a framework component would. The architecture removes the fixed cost - framework parse, bootstrap, virtual DOM setup, the tree-walk hydration pass - not the variable cost of whatever your component actually does. Keep that distinction precise; claiming automatic zero TBT for arbitrary component logic is the kind of overclaim a reviewer will catch immediately.
A minimal, genuinely zero-dependency interactive element
// copy-button.js - no build step required to ship this file as-is.
class CopyButton extends HTMLElement {
static observedAttributes = ['data-text'];
connectedCallback() {
// Runs once, when the element is inserted into the DOM.
// No framework bootstrap precedes this.
this.button = document.createElement('button');
this.button.textContent = 'Copy';
this.button.addEventListener('click', () => this.copy());
this.replaceChildren(this.button);
}
async copy() {
const text = this.dataset.text ?? this.textContent;
await navigator.clipboard.writeText(text);
this.button.textContent = 'Copied';
setTimeout(() => { this.button.textContent = 'Copy'; }, 1500);
}
}
customElements.define('copy-button', CopyButton);
<script type="module" src="/components/copy-button.js"></script>
<copy-button data-text="npm install">npm install</copy-button>
The element is inert HTML - server-rendered, readable by any crawler, present in the accessibility tree - until the module loads and customElements.define() registers the tag. No hydration pass, no virtual DOM, no framework code shipped for a widget this small. The module is roughly the size of the logic it contains, because there’s no runtime underneath it.
Declarative Shadow DOM: server-rendered encapsulation without a client walk
Shadow DOM’s traditional limitation was that it required JavaScript to attach - which meant a server-rendered page had unencapsulated markup until a script ran. Declarative Shadow DOM (Baseline across major engines since 2024) closes that gap:
<theme-card>
<template shadowrootmode="open">
<style>
:host { display: block; border-radius: 0.5rem; }
.card { padding: var(--space-m); background: var(--surface); }
</style>
<div class="card"><slot></slot></div>
</template>
<p>This content is slotted from light DOM.</p>
</theme-card>
The browser attaches the shadow root during HTML parsing - before any JavaScript runs, and even if no JavaScript ever runs. Style encapsulation, without a client-side attachment step. A crawler receiving raw HTML (see this site’s article on AI crawlers and hydration) sees fully composed content, because the composition happened at parse time, not at hydration time.
The caveat that belongs here: shadow DOM isolates the elements inside it from ordinary CSS selectors and, more relevantly for machine readers, can complicate naive HTML-to-text extraction if content that matters is nested deep inside multiple shadow boundaries with no light-DOM fallback. Keep primary content - the text a reader or a crawler needs - in light DOM slotted into the shadow template, as the example above does, rather than baked into the shadow tree itself. That preserves both encapsulation and readability.
Surviving aggressive CDN caching
A framework component’s rendered output frequently depends on client-side state that a CDN cannot know about - which is exactly why personalized or stateful UI regions get excluded from edge caching, or wrapped in awkward client-only islands.
A custom element with no server dependency is cacheable as plain HTML, indefinitely, because its behavior isn’t baked into the served markup - it’s attached by a separately-cached, separately-versioned JS module at parse time:
Cache-Control: public, max-age=31536000, immutable
on the HTML and the component module, independently. Ship a new version of <copy-button>’s behavior without touching the HTML that uses it anywhere on the site. This is a genuine caching advantage over server-templated interactivity, and it’s a genuine advantage over a framework bundle that couples markup and behavior into one build artifact that must be invalidated together.
When a framework is still the right call
This site does not build every interactive surface as raw custom elements, and presenting that as the universal answer would be dishonest. Reach for a framework - or a component library built on Lit or similar, which adds ergonomics over raw custom elements without reintroducing hydration - when you have:
- Complex, deeply nested state shared across many components
- A large team that benefits from a framework’s conventions and tooling
- Genuine application state - not content - that the DOM alone is a poor model for
Reach for native custom elements when the unit is small, isolated, and mostly presentational-with-a-bit-of-behavior: a copy button, an accordion, a tab panel, a toast, a carousel. That’s most of what a content site or marketing site actually needs interactive, and it’s exactly the category where framework overhead has historically been hardest to justify.
Frequently asked questions
Do custom elements guarantee zero Total Blocking Time?
No - TBT reaching zero depends on what code runs in connectedCallback and elsewhere. What the architecture removes is the fixed framework cost: parse, bootstrap, and the hydration tree-walk. A custom element with expensive synchronous logic still blocks the main thread.
What is the hydration “uncanny valley,” precisely?
The interval between first paint and hydration completing, during which a server-rendered framework page appears fully interactive but isn’t - because the framework hasn’t yet walked the DOM to attach event listeners.
Does Declarative Shadow DOM require JavaScript?
No. The browser attaches the shadow root during HTML parsing, before any script runs. This is what distinguishes it from the older attachShadow() API, which required a script to execute first.
Are custom elements readable by AI crawlers?
Yes, provided the content is in light DOM (slotted) or in a declaratively-attached shadow root present in the raw HTML response - both are visible without JavaScript execution. Content that only exists after a script imperatively attaches shadow DOM is not visible to non-JS-executing crawlers, the same limitation covered in this site’s article on SPA hydration and crawler visibility.
Should I replace my whole React app with custom elements?
Not as a blanket rule. Custom elements are the right fit for isolated, presentational-with-behavior widgets - the majority of what a content or marketing site needs. Complex shared application state is still a legitimate case for a framework.
Do I need a build step to use custom elements?
No - the minimal example in this article ships as a plain .js module with no compilation. A build step becomes useful for TypeScript, bundling many small components, or using a library like Lit, but it’s optional, not required by the platform.
Sources
- MDN: Using custom elements
- MDN: Declarative Shadow DOM
- Vercel: The Rise of the AI Crawler - relevant to the shadow-DOM/crawler-visibility caveat above