This happens because of a timing race, not a capability gap. Google’s Web Rendering Service (WRS) uses a Chromium-based renderer that is technically fully capable of upgrading Web Components, the same engine capability that makes them work in your local Chrome DevTools session. The difference is that when you test manually in DevTools, you (a human) implicitly wait as long as it takes, scroll the page, trigger intersection observers, and generally give every deferred script all the time and interaction it needs to fire. Googlebot’s rendering pass operates within a bounded process: it loads the page, executes JavaScript, and captures a snapshot of the DOM after some finite amount of processing, without manually scrolling, clicking, or otherwise interacting with the page the way a person does. If a custom element’s customElements.define() call is deferred behind a dynamic import, an intersection observer, a user-interaction listener, or any other conditional trigger that never fires during that automated pass, the element never upgrades from its unstyled, contentless placeholder state, and the snapshot captures it exactly as it appeared before the browser had a reason to insert content into it.
Why this happens: the custom element upgrade lifecycle meets a render queue
Web Components have a specific lifecycle defined by the browser platform (documented in the WHATWG HTML spec and summarized well by MDN): an element like <my-widget> sits inert in the DOM as an “undefined” custom element until JavaScript calls customElements.define('my-widget', MyWidgetClass). Only after that call runs does the browser “upgrade” every matching element on the page, invoking its constructor and lifecycle callbacks (like connectedCallback), which is typically when the element’s actual content gets created and inserted, often into a shadow root. Before that define call executes, the element is present in the DOM tree as a tag, but it’s functionally an empty container. This is standard, spec-defined behavior, not a bug, and it works identically in every Chromium-based browser, including whatever Chromium version WRS is built on.
The problem is entirely about when that define call runs relative to when Google captures its rendered snapshot. Google’s own documentation on JavaScript SEO basics describes rendering as happening in a queue: pages are crawled, then queued for rendering as resources allow, and the renderer executes a page’s JavaScript up to some bounded point before capturing the DOM used for indexing. That process does not include arbitrary human-like interaction. If your custom element’s define call is gated behind any of the following, it’s a strong candidate for appearing empty in Google’s rendered snapshot even though it upgrades fine the moment you, a patient human with a mouse and unlimited attention span, load the page in DevTools:
- Dynamic
import()triggered by a condition that may not resolve in time (for example, waiting on another async operation, a feature-detection check, or a delayed script tag). - IntersectionObserver-gated definition, where the component only defines itself once it (or some trigger element) scrolls into view, something that requires either scroll position changes or a viewport tall enough that the crawler’s default viewport already includes it, neither of which is guaranteed during an automated render pass.
- User-interaction-gated definition, such as defining the component only on a click, hover, or focus event, none of which an automated crawl performs.
- Deferred/async script loading with no execution-order guarantee relative to when the renderer captures the DOM, where the define call simply hasn’t run yet by the time the snapshot is taken, independent of any explicit gating logic.
Because Chrome DevTools testing almost always involves a fully loaded, fully interacted-with page (you’ve scrolled, you’ve waited, dev tools itself keeps the page and its timers running), it systematically hides this race condition. The manual test and the automated crawl are running the exact same browser engine but under fundamentally different interaction and timing conditions, which is why “it renders fine when I look at it” and “it’s empty in Google’s rendered HTML” can both be true simultaneously.
It’s worth being precise about what isn’t happening here: this is not a case of custom elements being unsupported by Googlebot’s renderer, or shadow DOM content being excluded from indexing as a category. WRS is Chromium-based and Web Components are a standard browser platform feature, fully supported. Google hasn’t published an exact timeout duration or resource budget for the render pass, and asserting a specific number here would be fabrication, but the mechanism (a bounded automated pass versus unbounded human interaction) is well-documented and is the correct explanation regardless of the exact numbers involved.
How to prevent custom elements from rendering empty for Googlebot
Define custom elements eagerly and synchronously for anything containing SEO-critical content. If a component holds text, headings, or links you need indexed, call customElements.define() as part of the main, synchronously-executing script path, not behind a dynamic import, an observer callback, or an interaction listener. The upgrade should be able to happen as soon as the browser parses the tag and runs your top-level script, with no dependency on scroll position or user behavior.
Reserve lazy-defined custom elements for genuinely non-critical, below-the-fold interactive widgets. Deferred hydration patterns are a legitimate performance technique, the problem is applying them indiscriminately to components that also happen to hold your indexable content. Separate “this widget is expensive and can wait” from “this text needs to exist in the DOM regardless of when it renders.”
Prefer patterns that put content in light DOM or declarative shadow DOM rather than relying entirely on JavaScript-driven content insertion. If critical text can be present in the initial server-rendered HTML (even if a custom element later enhances it), you remove the dependency on the upgrade lifecycle entirely for that content’s presence in the DOM, only the enhancement behavior is dependent on JavaScript execution.
Verify directly with URL Inspection’s “View Crawled Page” rather than trusting DevTools alone. Since the DevTools test and the actual crawl operate under different conditions, the only reliable check is looking at what Google’s renderer actually produced for the URL in question. If the custom element appears empty there while looking correct in a normal browser session, that’s direct confirmation of the timing race described above, and the fix is to move the define call earlier in the execution path, not to assume there’s a fundamental incompatibility to work around.
Test with JavaScript execution constrained, not just disabled. Rather than only checking the “JavaScript off” case (which tells you little about a modern component-based site), simulate a bounded, non-interactive execution pass, load the page fresh, don’t scroll, don’t click, and inspect the resulting DOM after a short fixed wait. That’s a closer approximation of what an automated renderer experiences than a normal interactive DevTools session.