A custom element's connectedCallback reads this.querySelector('.label') and gets null when the defining script runs inline in <head>, but finds the element when the same script is loaded as a module at the end of <body>. Why does the timing change the result?
answer
- streaming parser, start tag comes first
- two paths: immediate creation vs upgrade
- defer and module run after parsing
- readyState tells you if the parser is still running
- the component cannot control how it is loaded
basics
~20 sWith the definition already registered, the HTML parser upgrades the element the moment it sees the start tag, so connectedCallback runs before the children are parsed. When the definition arrives after parsing, the upgrade finds a complete element, so the children are already there.
solid answer
~50 sIt comes down to when the upgrade happens relative to parsing. If the definition is registered before the parser reaches `<my-card>`, the element is constructed and connected as soon as the start tag is processed — its children have not been parsed yet, so `this.querySelector('.label')` is `null`. A module script, or any `defer` script, executes after the document has been parsed, so by the time `customElements.define()` runs, every matching element already exists complete with children and is upgraded in place: `attributeChangedCallback` replays for each observed attribute, then `connectedCallback` sees the full subtree. Relying on that is fragile, because it silently breaks if the script is ever moved or inlined. The robust fixes are to not depend on light-DOM children at connect time — render into a shadow root and let content project — or to detect the parsing case with `document.readyState === 'loading'` and defer the read until `DOMContentLoaded`.
go deeper
Know that connectedCallback means 'this element is now in the document', not 'this element is fully parsed', and that children written inside it may not exist yet.
Explain the two creation paths — immediate parser creation when the definition is already registered, versus upgrade when it arrives later — and why defer or module scripts land you in the second one.
Diagnose it from the symptom, and choose a fix that does not depend on the host page's script placement: the readyState/DOMContentLoaded guard, or a design that projects content instead of reading children at connect time.
Set the expectation for a shared component library: components must behave identically whether loaded inline, deferred, or lazily imported, because consumers will change that. Make load-order independence a review criterion, not a bug you fix per component.
## Two different moments called "the element appears" A custom element instance is produced by one of two mechanisms, and they sit on opposite sides of parsing. **Immediate creation.** The definition is already in the registry when the parser reaches the start tag. The parser creates the element as an instance of your class straight away, and inserting it into the document fires `connectedCallback` right then. The parser has not yet read a single byte of the element's inner markup, so the element is genuinely empty at that moment. **Upgrade.** The parser reaches `<my-card>` with no definition registered. It creates a plain `HTMLElement` marked as an undefined custom element, parses the children into it normally, and moves on. Later, `customElements.define('my-card', MyCard)` runs and every matching element in the document is upgraded: your constructor runs on the existing instance, `attributeChangedCallback` is replayed for each observed attribute currently present, and `connectedCallback` is called — now with the full subtree in place. An inline script in `<head>` puts you in the first case. A `type="module"` script, or a classic script with `defer`, is executed only after the document has finished parsing, which puts you in the second. ## Why the parser cannot do better This is not a browser bug or an ordering accident. HTML parsing is streaming: the parser inserts an element into the tree the moment it sees the start tag, so incremental rendering can begin, and the closing tag may be kilobytes and one network round trip away. `connectedCallback` means "you are now in the document", and that is true at the start tag. There is no "your children have finished parsing" callback in the platform. ## The fixes, in order of robustness **1. Do not read light-DOM children at connect time.** Render your own UI into a shadow root and let the author's content project into it. Projection is resolved by the browser as content arrives, so late-parsed children simply show up. This is why component libraries so rarely hit the problem: it does not arise for them. **2. Wait for parsing to finish when you must read children.** Detect the case explicitly rather than relying on where the script tag happens to sit: ```js connectedCallback() { if (this.ownerDocument.readyState === 'loading') { this.ownerDocument.addEventListener( 'DOMContentLoaded', () => this.#init(), { once: true } ); } else { this.#init(); } } ``` `readyState === 'loading'` is true exactly while the parser is still running, which is the only situation in which children can be missing. Note that this deliberately does *not* delay elements created later at runtime — those always arrive complete. **3. Load the definition with a deferred or module script.** Making the whole registration happen after parsing turns every element on the page into the upgrade case. It works, and it is what most sites do accidentally, but it is a page-level accident rather than a component-level guarantee: someone who inlines your bundle for performance reintroduces the bug. ## Related tools `customElements.whenDefined('my-card')` returns a promise resolving with the constructor once the name is registered. It is for code waiting on *someone else's* definition — gating a `:not(:defined)` loading state, or waiting before setting properties on a third-party element — not for solving the children-not-parsed problem, which is about the parser, not the registry. `customElements.upgrade(root)` forces upgrade of already-created elements inside a subtree without waiting for them to be inserted into the document. That matters when you build a detached subtree with `innerHTML` or clone one from a fragment and want the instances to be real class instances before you insert them. ## The wider lesson A custom element cannot assume anything about *when* it runs relative to its own markup, because that depends on how the page loaded the definition — inline, deferred, dynamically imported, lazily on interaction — and a component author does not control that. Well-behaved components are written to be correct in every one of those orderings: read attributes through `attributeChangedCallback`, treat light-DOM children as content that may arrive late, and make `connectedCallback` idempotent. Components that only work when the bundle is at the end of `<body>` are carrying an undeclared dependency on the host page's loading strategy.
- Does customElements.whenDefined() solve this problem?No. It resolves once the *name* is registered, which says nothing about whether a particular element's children have been parsed. It is the right tool for code waiting on a definition it does not own — gating a `:not(:defined)` placeholder or delaying property assignment on a third-party element — but the missing-children case is a parser-timing issue.
- What is customElements.upgrade(root) for?It forces upgrade of already-created custom elements inside a subtree instead of waiting for insertion into the document. You need it when you build a detached tree — `innerHTML` on an orphan div, or a cloned fragment — and want real class instances, with their constructors run, before that tree is inserted.
- Why does the same component work fine when a script creates it with document.createElement and appends it?Because programmatic creation never involves the streaming parser. The code creates the element, populates it, and appends it, so by the time `connectedCallback` fires the children are already there. That asymmetry is what makes the bug look intermittent: it reproduces only for elements written in the initial HTML.
saying these in an interview costs you the question
- Calls it a browser bug rather than streaming-parse behaviour
- Fixes it with a setTimeout(0) and calls it done
- Thinks whenDefined guarantees children are parsed
- Assumes connectedCallback always sees the full subtree
- Relies on the bundle being at the end of body as the fix