Detecting Slot Changes with slotchange: The Only Safe Read Point for Projected Content

Every component that reacts to what a consumer projects into it — collapsing an empty region, counting tabs, forwarding a heading level, wiring keyboard navigation across projected items — needs to know when that content is actually there. The obvious place to look is connectedCallback, and it is wrong often enough to be a reliable source of production bugs. The correct read point is the slotchange event, and understanding why requires understanding what the HTML parser has and has not done by the time your callback runs.

This page sits under Slot Composition & Content Projection in Core Architecture & Lifecycle Management. It reproduces the empty-read bug, grounds it in the parser’s incremental element upgrade, and gives a production pattern that behaves identically for parser-created, script-created, and server-rendered elements.

Problem Statement: The Component That Thinks It Is Empty

The component below tries to hide its footer region when no footer content is projected. It reads the slot once, at the end of connectedCallback. Loaded from a server as static HTML, it hides the footer even when the consumer supplied one.

<data-panel>
  <span slot="footer">Updated 4 minutes ago</span>
</data-panel>
class DataPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: block; border: 1px solid #cbd5f5; border-radius: 8px; padding: 1rem; }
        footer { margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.75; }
        footer[hidden] { display: none; }
      </style>
      <slot></slot>
      <footer><slot name="footer"></slot></footer>`;
  }

  connectedCallback() {
    const slot = this.shadowRoot.querySelector('slot[name="footer"]');
    const footer = this.shadowRoot.querySelector('footer');
    // BUG: reads assignment before the parser has appended the child.
    footer.hidden = slot.assignedElements().length === 0;
    console.log('assigned at connect:', slot.assignedElements().length);
  }
}
customElements.define('data-panel', DataPanel);

Run this against server-delivered HTML with the definition loaded in a blocking <script> in the head and the console prints assigned at connect: 0. The footer is hidden even though the <span slot="footer"> is sitting right there in the source. Move the same definition to a deferred module and the log prints 1, the footer appears, and the bug quietly disappears — which is exactly what makes it dangerous. It is timing-dependent, so it survives local development and surfaces after a bundler change.

Why the read at connection time returns zero The parser upgrades the custom element as soon as its start tag is seen, so connectedCallback runs before the slotted child element has been appended. Parser progress through the source bytes <data-panel> seen element created upgrade + connect host.children = 0 <span slot="footer"> appended to host slotchange count = 1 The read that fails connectedCallback samples assignedElements() at the red step, two parser steps before the child exists. The read that works A slotchange handler samples the same call at the green step, after composition has settled.

Root-Cause Analysis

The HTML Standard requires the parser to run the custom element reactions for an element as soon as its start tag has been processed and the element has been inserted into the document — not when its end tag is reached. A parser-created element is therefore constructed, upgraded, and connected while its own children are still being tokenised. The specification is explicit that connectedCallback may observe an incomplete child list; this is not an implementation quirk that a future browser will fix.

Slot assignment, defined by the DOM Standard’s assign slottables for a tree algorithm, runs whenever the host’s child list changes. Each appended child triggers a reassignment, and the affected slots have a slotchange event queued. That event is fired at the microtask checkpoint, which coalesces every mutation from the same task into one event per slot. So by the time any slotchange handler runs, assignment for that task is complete and assignedElements() returns a settled answer.

The reason the bug hides during development is script timing. A type="module" script is deferred, so the class definition is registered after the parser has finished the document. The element is then upgraded by customElements.define, which runs the upgrade reaction on an element that already has all its children — so the read at connection time happens to be correct. Switch to a classic blocking script, add a preload, or server-render with an inline definition, and the upgrade moves back to parse time where the child list is empty.

Debugging Pitfall — setTimeout(…, 0) is not a fix. Deferring the read by a task usually makes the symptom go away, which is why it appears in so many codebases. It is still wrong: a consumer that appends footer content later, or a framework that patches children during hydration, changes assignment after your timeout has already fired, and the component never updates. The event exists precisely so that the component tracks assignment for its whole lifetime rather than sampling it once at an arbitrary moment.

There is a second, quieter consequence of coalescing. slotchange fires only for slots whose assignment actually changed. A slot that starts empty and stays empty never fires at all, so an initialiser that lives only inside the handler never runs for the empty case. Any state derived from projection therefore needs a defined initial value in the template — hidden present by default, a count of zero, a disabled control — with the handler correcting it once content arrives.

Task and microtask ordering for slot assignment and slotchange Three child appends in one task produce three synchronous assignment passes but a single coalesced slotchange event at the microtask checkpoint. One task append child 1 assign runs append child 2 assign runs append child 3 assign runs Microtask checkpoint 1 slotchange event state already settled Handlers see state, not deltas Three appends, one event: derive from assignedElements(), never accumulate per-call counters. The silent case A slot that is never filled never fires, so the empty state must be the template default.

Production-Safe Fix

The pattern below is the one to standardise on. State is derived from a single function, the template ships the empty state as its default, and every listener is scoped to an AbortSignal so a moved component does not accumulate duplicates.

class DataPanel extends HTMLElement {
  #controller = null;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: block; border: 1px solid #cbd5f5; border-radius: 8px; padding: 1rem; }
        footer { margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.75; }
        footer[hidden] { display: none; }
      </style>
      <slot></slot>
      <!-- Ships hidden: the empty state is the default, the event corrects it. -->
      <footer hidden><slot name="footer"></slot></footer>`;
  }

  connectedCallback() {
    this.#controller = new AbortController();
    const { signal } = this.#controller;
    const slot = this.shadowRoot.querySelector('slot[name="footer"]');

    slot.addEventListener('slotchange', () => this.#syncFooter(slot), { signal });
    // Covers the script-created case, where children exist before connection
    // and no slotchange will ever fire for them.
    this.#syncFooter(slot);
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }

  #syncFooter(slot) {
    // assignedElements() ignores whitespace text nodes; assignedNodes() would not.
    const projected = slot.assignedElements();
    const footer = this.shadowRoot.querySelector('footer');
    footer.hidden = projected.length === 0;
    this.dispatchEvent(new CustomEvent('footer-change', {
      detail: { count: projected.length },
      bubbles: true,
      composed: true
    }));
  }
}
customElements.define('data-panel', DataPanel);

Three decisions in that code carry the correctness:

  1. The template ships the empty state. <footer hidden> means a component whose footer slot is never filled is correct without any event having fired.
  2. The same derivation runs from both entry points. Calling #syncFooter once at the end of connectedCallback covers document.createElement('data-panel') followed by append, where the children are present before connection and no slotchange follows. Running it again from the event covers the parser and every later mutation. Because the function derives state rather than toggling it, running twice is harmless.
  3. Teardown is unconditional. disconnectedCallback aborts, and the next connection creates a fresh controller. Moving the element with append — which disconnects then reconnects — leaves exactly one listener.

For components that need the effective content rather than the assigned content, pass { flatten: true }. That resolves slots nested inside other slots and returns fallback content when nothing is assigned, which is what you want when a component wraps another component that itself projects.

Verification

Assert the behaviour at the two moments that differ, not just the happy path:

// 1. Parser-created path: the element and its child come from markup.
document.body.innerHTML = '<data-panel><span slot="footer">ok</span></data-panel>';
await new Promise((resolve) => queueMicrotask(resolve));   // let slotchange flush
const parsed = document.querySelector('data-panel');
console.assert(!parsed.shadowRoot.querySelector('footer').hidden, 'footer must be visible');

// 2. Script-created path: children appended before connection, no slotchange fires.
const made = document.createElement('data-panel');
const span = document.createElement('span');
span.slot = 'footer';
span.textContent = 'ok';
made.append(span);
document.body.append(made);
console.assert(!made.shadowRoot.querySelector('footer').hidden, 'sync fallback must run');

// 3. Empty path: no event ever fires, the template default must hold.
const empty = document.createElement('data-panel');
document.body.append(empty);
console.assert(empty.shadowRoot.querySelector('footer').hidden, 'empty footer stays hidden');

In DevTools, the fastest confirmation is to select the host in the Elements panel and expand #shadow-root. A correctly composed slot shows a reveal link beside it pointing at the light-DOM node it received; an empty slot shows its fallback content instead. Switching to the Event Listeners tab and confirming exactly one slotchange entry after moving the element around the DOM verifies the teardown.

When to Use vs When to Avoid

Situation slotchange MutationObserver on the host One-shot read
React to what a consumer projects Use — reports settled assignment Overkill, fires on unrelated mutations Unreliable at parse time
React to attribute changes on projected nodes No — assignment did not change Use — observe attributes with subtree No
Count or index projected items Use with assignedElements() Redundant Only if content is guaranteed static
Detect content inside a projected node No — nested changes do not reassign Use No
Component created entirely by script Pair with a sync call at connect Not needed Acceptable but fragile

The rule of thumb: slotchange answers “which nodes are assigned to this slot”, and nothing else. Any question about what is inside those nodes is a MutationObserver question, with the teardown obligations set out in disconnecting observers in disconnectedCallback.

Choosing between slotchange, MutationObserver, and a one-shot read A decision tree routing questions about which nodes are assigned to slotchange, questions about node internals to MutationObserver, and static content to a single synchronous read. What are you asking about? which nodes inside a node never changes slotchange counts, indexes, empty state coalesced, settled state read assignedElements() pair with a sync call MutationObserver attributes on projected nodes text inside projected nodes observe with subtree: true disconnect on removal one-shot read content guaranteed static no consumer mutation fragile: parse timing prefer the event anyway slotchange answers which nodes are assigned — and nothing else.

Frequently Asked Questions

Does slotchange bubble out of the shadow root?

No. The event is dispatched on the <slot> element with bubbles: true inside the shadow tree, but composed is false, so it stops at the shadow root and never reaches the host’s ancestors. Listeners must be registered inside the component, on the slot or on the shadow root.

Why does slotchange fire when I only changed whitespace?

Text nodes are slottables, and a reformatted template that puts children on separate lines adds whitespace text nodes to the default slot. Reading with assignedElements() rather than assignedNodes() filters them out of your logic, though the event itself still fires.

Do I still need the synchronous call if I always create elements from markup?

Keep it. It costs one function call, it makes the component correct for consumers who build it with createElement, and it removes the class of bug where a test that constructs the element in script behaves differently from the browser that parses it.