A custom element clones a <style> block into every instance's shadow root, and one page renders several hundred instances. What does that cost, and what changes if the component assigns to shadowRoot.adoptedStyleSheets instead?
answer
- one style node per instance adds up
- construct once, adopt many
- replaceSync fills the sheet
- constructed sheets only, or it throws
- no @import in a constructed sheet
basics
~20 sEvery shadow root gets its own style element and its own CSSOM stylesheet object, so rules are duplicated per instance in memory and on every DOM insertion. adoptedStyleSheets lets many roots share one constructed CSSStyleSheet, parsed once and updated in one place.
solid answer
~50 sWith a `<style>` in each root, every instance owns a separate `<style>` node and a separate stylesheet in the CSSOM. The rule text is duplicated hundreds of times, each root's sheet has to be attached when the element is inserted, and a change to the styles means touching every instance. `adoptedStyleSheets` fixes the sharing problem: you construct one `CSSStyleSheet` with `new CSSStyleSheet()`, fill it with `replaceSync(cssText)`, and assign it to `shadowRoot.adoptedStyleSheets` in each instance. All roots then point at the same object — parsed once, held once — and calling `replaceSync` again updates every adopting root at once, including the document if it adopted the sheet too. The constraints are that only constructed sheets may be adopted (assigning a `<style>` element's sheet throws), `@import` is not honoured in a constructed sheet, and the sheet must belong to the same document as the roots adopting it.
code
javascript · 15 linesconst sheet = new CSSStyleSheet();
sheet.replaceSync(':host { display: block } .body { padding: 8px }');
class MyRow extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.adoptedStyleSheets = [sheet]; // same object in every instance
root.innerHTML = '<div class="body"><slot></slot></div>';
}
}
customElements.define('my-row', MyRow);
// One call restyles every instance already on the page:
sheet.replaceSync(':host { display: block } .body { padding: 2px }');go deeper
Know that a shadow root can get its CSS either from a <style> element inside it or from a constructed CSSStyleSheet assigned to shadowRoot.adoptedStyleSheets.
Explain the mechanics: new CSSStyleSheet() plus replaceSync builds the sheet, assignment adopts it, only constructed sheets are accepted, and adopted sheets apply on top of the root's own style elements.
Reason about scale. Say where the per-instance cost actually shows up — heap and insertion time at hundreds of instances — and describe how you would measure it before switching rather than optimising on instinct.
Set the library-wide convention: which components construct sheets at module load, how CSS reaches JavaScript in the build, and what the fallback is for engines or embedding contexts where the constructed-sheet path is unavailable.
## What the per-instance <style> actually costs The idiomatic first version of a component puts markup and CSS in one template and clones it: ```js const tpl = document.createElement('template'); tpl.innerHTML = `<style>/* 200 lines */</style><div class="body"></div>`; class MyRow extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true)); } } ``` That is correct and, for a handful of instances, entirely fine. At several hundred instances the arithmetic changes. Each root now holds its own `<style>` element and its own `CSSStyleSheet` in the CSSOM, with its own `cssRules` list. Engines can share some parsed representation for identical text, but you still pay for a node per instance, a sheet object per instance, and the work of attaching that sheet to the tree when the element is inserted. On a table with 500 rows, that shows up as slower insertion and a heavier heap — and it is a cost that grows with a number the component author does not control. There is a maintenance cost too: styles live inside the cloned markup, so a runtime change means walking every root. ## What adoptedStyleSheets changes `DocumentOrShadowRoot.adoptedStyleSheets` is an array of *constructed* stylesheets that a document or shadow root applies in addition to whatever `<style>`/`<link>` it contains. The sheet is built once, at module scope: ```js const sheet = new CSSStyleSheet(); sheet.replaceSync(` :host { display: table-row } .body { padding: 4px 8px } `); class MyRow extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: 'open' }); root.adoptedStyleSheets = [sheet]; root.append(tpl.content.cloneNode(true)); // template no longer carries <style> } } ``` Now one `CSSStyleSheet` object exists no matter how many instances render. Parsing happens once, at module evaluation. Adopting is a cheap array assignment rather than a node insertion. And because every root references the same object, mutating it propagates: a later `sheet.replaceSync(newCss)` — or `sheet.insertRule(...)` — updates every adopting root in one step, with no traversal. The array is ordinary and mutable in current browsers, so a root can combine sheets and add one later: ```js root.adoptedStyleSheets = [baseSheet, variantSheet]; root.adoptedStyleSheets.push(extraSheet); ``` In the original implementation the array was frozen and had to be reassigned wholesale; mutation landed in Chrome 99, Firefox 101 and Safari 16.4, so on anything current either style works. ## The rules and the sharp edges - **Only constructed sheets.** You may adopt a sheet created with `new CSSStyleSheet()`. Assigning a sheet obtained from `document.styleSheets` or from a `<style>` element's `.sheet` throws — the platform does not let you re-home a sheet that already belongs to a tree. - **Same document.** A constructed sheet is associated with the document it was constructed in. Adopting it into a root in a different document (an `<iframe>` document, for instance) is not allowed; construct it there instead. - **`@import` is not honoured** in a constructed stylesheet, so `replaceSync` cannot pull in another file. Inline everything, or fetch the text yourself and pass it in. - **`replace()` vs `replaceSync()`.** `replaceSync(text)` applies synchronously and is what component constructors use. `replace(text)` returns a promise and exists for the asynchronous case; both wipe the sheet's existing rules first. - **Ordering.** Adopted sheets are applied after the tree's own `<style>` and `<link>` sheets, which matters when a component keeps a small inline block for per-instance overrides. ## When the simple version is still right Do not reach for this reflexively. If a component renders a handful of times per page, the per-instance `<style>` is simpler, keeps CSS next to markup, and needs no build step to turn a `.css` file into a JS string. `adoptedStyleSheets` earns its place when instance counts are large or unbounded (rows, cells, list items, chips), when several roots must share one sheet whose rules change at runtime, or when you want the sheet parsed once during module load rather than at first render. The honest senior answer is a threshold answer: name the mechanism, name the cost it removes, and say that the cost only matters above a certain instance count — then say how you would confirm it with a memory snapshot and an insertion-time measurement rather than assuming.
- You call replaceSync on a sheet that ten shadow roots have adopted. What happens to those roots?All ten restyle. The roots hold a reference to the same `CSSStyleSheet` object, so replacing its rules invalidates style for every tree that adopted it — no traversal, no per-instance work. That shared-mutation property is the main reason to prefer it over cloning a `<style>` when styles change at runtime.
- Can you put an @import in a constructed stylesheet?No — `@import` is not honoured in a constructed sheet, so `replaceSync` will not pull in an external file. Either inline the imported rules into the text you pass, or fetch the CSS yourself and hand the resulting string to `replaceSync`. Build tooling that inlines CSS into a JS string is the usual answer.
- Does adoptedStyleSheets replace the shadow root's own <style> elements?No, it adds to them. A root can carry both, and adopted sheets are applied after the tree's own `<style>` and `<link>` sheets. That combination is useful when a shared constructed sheet holds the bulk of the rules and a small inline block carries something specific to one instance.
- When would you keep the per-instance <style> block instead?When instance counts are small and bounded. The inline block keeps CSS beside the markup it styles, needs no build step to become a JavaScript string, and costs nothing measurable at a handful of instances. Switch when the count is large or unbounded, or when several roots must share rules that change at runtime.
saying these in an interview costs you the question
- Says a <style> per shadow root costs nothing at any scale
- Tries to adopt a <style> element's sheet and expects it to work
- Believes adoptedStyleSheets replaces the root's own style elements
- Thinks @import works inside a constructed stylesheet
- Reaches for constructed sheets before any instance count justifies it