Short answer -- nothing fundamental. Web Components are a set of browser-native APIs that solve real problems in web development. They let you do things that are either impossible or unnecessarily complex without them. And when a task goes slightly beyond standard requirements and needs a creative approach, Custom Elements give you a level of flexibility that framework-specific abstractions simply don't offer.
Yet somehow, the narrative online keeps cycling back to "Web Components are a failure". I've been using these APIs in production for years -- starting from the early Polymer drafts, through the spec evolution, and up to modern Lit and my own library Symbiote.js. Over that time I've shipped large real-time dashboards with graph data flowing through WebSockets, collections of hundreds of thousands of items, and plenty of simpler projects. So I have opinions, and they come from practice.
One thing before we start: every engineering decision is a trade-off. There are no perfect technologies (if someone claims otherwise, they're selling something). When evaluating any tool, we weigh pros and cons. The existence of cons doesn't automatically invalidate the pros. Each trade-off has a concrete cost. If that cost is acceptable for your case -- the technology deserves serious consideration.
Myths That Won't Die
Some criticisms of Web Components are simply inaccurate.
"You can only pass strings to Web Components." This one references attributeChangedCallback and suggests JSON-parsing attribute values. But HTML attributes are part of HTML markup -- they're always strings, for any element, not just custom ones. If you need to pass complex data to a component at runtime, use property setters, getters, or Proxies. Standard JS. Every modern framework can set properties on DOM elements through their template syntax, and plain DOM API works just as well. Confusing attributes (HTML serialization) with properties (JS runtime) is a fundamental misunderstanding of how the browser works.
"Shadow DOM isolation is a problem." Shadow DOM isolates a component's internals from external CSS. That's its purpose. If you don't need isolation -- don't use Shadow DOM. Custom Elements and Shadow DOM are independent APIs. You can use Custom Elements with Light DOM just fine, and many production component libraries do exactly that. When you do want Shadow DOM, you still have CSS custom properties (design tokens) and ::part() for controlled styling from outside. Adopted Style Sheets handle shared styles across instances without duplication -- supported in all modern browsers.
"The lifecycle starts at DOM attachment." It doesn't. A Custom Element's lifecycle starts at the constructor. You can create elements in memory, manipulate them within a DocumentFragment through standard DOM API, and only attach them when ready. This is fundamental to virtualization patterns.
Nuances Worth Understanding
These aren't dealbreakers, but they're real and worth knowing about.
Global name registry. Custom element names share a single flat namespace per document. This is true. The fix is simple: use prefixes (or postfixes) in naming conventions. If a collision happens, the browser throws a clear error at registration time, not a silent failure. You can catch it in integration tests. You can subclass and re-register. Flat namespaces are one of the oldest patterns in computing -- from object keys to domain names. It works fine with minimal discipline.
connectedCallback / disconnectedCallback re-firing. Moving an element from one DOM location to another triggers both callbacks. This is by design -- the element literally disconnects and reconnects. A single boolean flag on first initialization handles this. It's a one-line solution for a rarely-encountered scenario. The ability to track DOM lifecycle from inside the component is one of the most powerful features of the standard.
No de-registration. Once registered, a custom element stays registered for the life of the document. True. The browser stores a reference to the constructor. Memory cost is negligible, performance impact is zero. De-registration would actually create harder problems: what happens to existing instances? Which class is active? The current behavior is predictable.
The Real Gotcha
There is one aspect that genuinely surprises newcomers. When the browser parses HTML in a streaming fashion, connectedCallback fires as soon as it encounters the opening tag -- before the element's children are parsed. DOM queries for child elements return nothing at that point.
The fix: load your script with defer or type="module". Both ensure execution after parsing completes. In Symbiote.js, there's a dedicated renderCallback lifecycle hook that guarantees child DOM access.
The inverse scenario exists too: you might get a reference to an element before its class is registered. customElements.whenDefined(name) returns a Promise for exactly this case.
This async behavior can be confusing if you're not aware of it. But it's the same category as losing this context in callbacks -- once you know the pattern, you handle it automatically.
AI Tooling and Core Standards
Here's an angle that rarely comes up in Web Components discussions. Modern AI coding assistants -- LLMs, autocomplete models, chat-based agents -- have been trained on massive amounts of web platform documentation. The DOM API, Custom Elements, Shadow DOM, HTML templates, CSS custom properties: these are extensively documented on MDN, in W3C specs, and across millions of tutorials and StackOverflow answers.
When you write a Custom Element using standard APIs, AI tools understand what you're doing. They can generate connectedCallback logic, suggest attribute observation patterns, autocomplete CSS custom property usage. The entire surface area of these APIs is well-represented in training data.
Framework-specific abstractions accumulate across versions. React Hooks have different idioms than class components. Angular standalone components differ from NgModule-based patterns. Vue 2 Options API vs. Vue 3 Composition API. Each version shift creates a window where AI models produce outdated or mixed-version code.
Web Components API has been stable. connectedCallback works today exactly as it did years ago. That stability maps directly to reliable AI assistance.
SEO and Shadow DOM
Search engines have improved their ability to process JavaScript-rendered content, but there's still a practical distinction. Content in Light DOM is part of the main document tree -- crawlers parse it the same way they parse any HTML. Content inside Shadow DOM sits in a separate tree.
Google's crawler can execute JavaScript and reach into Shadow DOM in many cases, but other search engines and social media parsers (OpenGraph scrapers, link previews) often cannot. If your page content, headings, structured data or link anchors live inside Shadow DOM, you're limiting their visibility.
The practical approach: use Custom Elements without Shadow DOM for content-bearing components or place such content in Light DOM slots. Keep your semantic HTML, headings and paragraphs in Light DOM where every crawler can reach them. Reserve Shadow DOM for UI widgets that don't carry indexable content -- toolbars, media players, interactive controls.
This is a real architectural advantage. You get component encapsulation at the JS level (Custom Elements), semantic HTML for crawlers and accessibility tools, and styling flexibility through the cascade -- all without a build step that transforms your markup beyond recognition.
What Only Web Components Can Do
Enough about trade-offs. What are the capabilities that no framework can replicate?
HTML-to-runtime binding at the node level.
Frontend developers tend to forget that HTML is the foundation. A page starts as a text document, often assembled server-side, where no JS abstractions exist yet. Custom tags in that document are native markers for your JS integration. They provide natural mounting points and an XML-compatible structure that works with standard HTML -- no proprietary template syntax, no vendor-specific compilation step. A server written in any language can emit <my-widget data-id="42"></my-widget> without knowing anything about your frontend stack. Try doing that with a React component.
For example, this native binding radically simplifies SSR (Server-Side Rendering). Look at the architectural overhead required to hydrate a Next.js or Nuxt application: serialization boundaries, matching virtual DOM trees, and shipping a heavy hydration engine. Compare that to the Symbiote.js SSR approach: the server simply produces standard HTML with custom tags and declarative attributes. When it reaches the browser, the Custom Element initializes, detects the pre-rendered children, and attaches its logic directly to the existing DOM nodes. There is no virtual tree to reconcile -- the HTML structure itself tells the runtime exactly what to do. No mismatch errors by design, no extra bundle size.
Atomic lifecycle management.
Custom Elements let you know, from inside the component, when it enters or leaves the DOM. Component appeared -- you know. Component removed -- you know. Regardless of what created it or what triggered a re-render.
Without Custom Elements, lifecycle management is always external. You need a runtime to search the DOM for integration points, decide when to initialize, and decide when to clean up. That runtime has to be loaded, parsed, and executed before any component becomes alive. In React, this is ReactDOM scanning for a root element and owning the entire subtree. In Angular, it's the platform bootstrap and zone.js. These aren't free -- they add tens of kilobytes of code just to answer the question "when does this piece of UI exist?"
Custom Elements answer that question natively. connectedCallback fires when the element enters any document -- a React app, a static HTML page, a server-rendered document, an email template rendered in an iframe. No library runtime required. No teardown logic to wire up from outside. The browser tells the component directly, and the component handles itself.
A universal component model.
Custom Elements are the base layer that everything else can build on. Libraries like Lit or Symbiote.js add convenience (reactive state, templating, etc). UI kits build design systems. Complex widgets encapsulate business logic. Micro-frontends compose applications from independent deployables. All of these benefit from a standardized component API with built-in documentation on MDN, universal browser support, and zero lock-in. A Custom Element works inside React, Angular, Vue, Svelte, plain HTML pages -- or alongside other Custom Elements. No wrapping, no adapters, no compatibility layers for the most cases.
Unmet Expectations
A lot of frustration with Web Components comes from hoping they'd be a native, highly-optimized replacement for React or Angular. They're not meant to be. The browser gives you a low-level component primitive. What you build on top of it is up to you.
That mismatch between expectation and reality doesn't make the standard bad. It means the standard solves a different problem than people assumed. And the problem it does solve -- a universal, framework-agnostic component model with native lifecycle tracking -- has no viable alternative in any library or framework today.
If you want to see these ideas in practice, check out Symbiote.js -- a lightweight library built on Custom Elements that adds very flexible reactive state and data binding while keeping you close to the platform.
Top comments (0)