What is the difference between attaching a shadow root with attachShadow({ mode: 'open' }) and attachShadow({ mode: 'closed' }), and is closed mode a security boundary?
answer
- one property changes: element.shadowRoot
- closed hides the handle, not the code
- same origin, same realm
- composedPath truncates at a closed root
- tooling pays the price
basics
~20 sOpen mode exposes the root as element.shadowRoot; closed mode makes that property return null, so only the code holding the returned root can reach inside. Closed is a discouragement, not a security boundary — same-page script can still get in.
solid answer
~50 sThe only real difference is discoverability from outside. With `mode: 'open'` the browser sets `element.shadowRoot` to the root, so any script on the page can walk in. With `mode: 'closed'` that property is `null`, and the only reference is the value `attachShadow` returned, which the component usually keeps in a private field. Closed also truncates `event.composedPath()` for listeners outside the root, so the internal path is not leaked. It is not a security boundary: the component and the page share one origin and one JavaScript realm, so page script that runs first can monkey-patch `Element.prototype.attachShadow` and capture every root that is later created. What closed actually buys is a signal that the internals are not API, at the cost of losing tooling — test runners, a11y auditors and your own debugging code can traverse open roots and cannot enter closed ones.
code
javascript · 14 linesconst host = document.createElement('div');
const root = host.attachShadow({ mode: 'closed' });
root.innerHTML = '<button id="go">go</button>';
console.log(host.shadowRoot); // null - no public handle
console.log(root.mode); // "closed" - if you hold it, you can ask
// A closed root still stops nothing that shares the realm:
const real = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
const r = real.call(this, init);
console.log('captured a', init.mode, 'root');
return r;
};go deeper
Know that open mode gives you element.shadowRoot and closed mode gives you null, and that open is the usual default when you create a component.
Explain both effects — the missing shadowRoot property and the truncated composedPath — and be able to state clearly that neither one keeps same-page script out.
Argue the real cost. Show that you have felt the debugging, testing and instrumentation loss from a closed root, and that you know cross-origin iframes are where genuine isolation lives.
Frame it as an API-surface policy for a whole library: what you commit to as public, what escape hatches you owe consumers if you close the door, and how you keep internals from ossifying when the roots are open.
## The mechanical difference `mode` is a required member of the `attachShadow` init dictionary and takes exactly two values. Both create the same kind of `ShadowRoot`, with identical rendering, style scoping and event behaviour. The difference is one property: ```js const openHost = document.createElement('div'); const openRoot = openHost.attachShadow({ mode: 'open' }); openHost.shadowRoot === openRoot; // true const closedHost = document.createElement('div'); const closedRoot = closedHost.attachShadow({ mode: 'closed' }); closedHost.shadowRoot; // null closedRoot.mode; // "closed" ``` With closed mode the returned root is the *only* handle. A component typically stores it privately: ```js class MyWidget extends HTMLElement { #root; constructor() { super(); this.#root = this.attachShadow({ mode: 'closed' }); } } ``` Note that `shadowRoot.mode` still reports `"closed"` — if you hold the root, you can ask what mode it was created with. ## The second effect: composedPath The mode also changes what an outside listener learns about an event's route. `event.composedPath()` returns the full propagation path, innermost node first. When the event crossed an **open** root, the shadow nodes appear in that array, so a document listener can see the exact inner element that was clicked. When it crossed a **closed** root, the path is truncated at the boundary: the outside listener sees the host and everything above it, and nothing below. That is consistent with the retargeting rule — `event.target` is the host in both cases — but closed mode additionally removes the escape hatch. ## Why closed is not security The common wrong answer is that closed mode protects component internals from a hostile page. It cannot, for a structural reason: the component's script and the page's script run in the **same origin and the same JavaScript realm**. Anything one can reach, the other can reach. Concretely: ```js // Page script, running before the component is defined: const realAttach = Element.prototype.attachShadow; const captured = new WeakMap(); Element.prototype.attachShadow = function (init) { const root = realAttach.call(this, init); captured.set(this, root); // every closed root, saved return root; }; ``` Nothing in the platform prevents this. The same is true of overriding the custom element's own methods, reading its private state through a patched prototype, or simply loading the component's source. Real isolation on the web comes from a different origin — a cross-origin `<iframe>`, which gets its own realm and its own security boundary — not from a shadow root mode. A related misconception is that closed mode hides markup. It does not: browser devtools show closed shadow trees, and the component's source is served to the page in plain text. ## What closed actually costs Closed mode is a strong statement that the internals are private implementation detail. That statement has a price, paid by everyone downstream: - **Testing.** Selector engines in test tools commonly traverse open shadow roots automatically and have no way into closed ones, so component tests must go through whatever API the component chooses to expose. - **Debugging and instrumentation.** A one-line console query into `el.shadowRoot` is unavailable; every investigation needs a hook the author remembered to provide. - **Accessibility and analytics tooling.** Anything that walks the DOM to audit or instrument the page hits a wall at the boundary. - **Your own future code.** Escape hatches are needed more often than component authors expect, and closed mode means every one of them must be designed, named and shipped. ## How to choose The practical default across shipped design systems and browser vendors' own guidance is **open**. It preserves tooling and debuggability, and it does not weaken anything real, because closed was never protecting anything real. Reach for closed only when the value is specifically "nobody should build on our internals, and I accept that I owe them an explicit API for everything they need" — for example a widget embedded in third-party pages where accidental coupling to internal structure would freeze your markup forever. Even then, the boundary is a convention that a determined script can bypass; treat it as a lock on a door, not a wall. A useful framing for the interview: open versus closed is an **API-surface decision**, not a security decision. Say that sentence, then justify the tradeoff.
- If closed mode is not a security boundary, what on the web actually is one?The origin. A cross-origin `<iframe>` gives the embedded content its own realm, its own globals and its own DOM, and the same-origin policy prevents script on either side from touching the other's document. Adding `sandbox` restricts it further. That is enforced by the browser, unlike a shadow root mode, which is enforced only by the absence of a convenient property.
- Can you tell from outside whether an element has a closed shadow root at all?Not directly — `el.shadowRoot` is `null` both for a closed root and for an element with no root. You can often infer it indirectly: the element renders content that does not correspond to any of its children, or an event's `composedPath()` starts at the host rather than at whatever visibly received the interaction.
- Your team ships a component library with closed roots and consumers complain they cannot write end-to-end tests. What are the options?Either expose the affordances explicitly — stable public methods, dispatched events, delegated focus — so tests drive the component through its API, or switch the roots to open and rely on documentation plus review to discourage internal coupling. The second is the common resolution, because the encapsulation you lose was never enforceable anyway.
saying these in an interview costs you the question
- Says closed mode protects internals from malicious page script
- Thinks closed shadow trees are hidden from devtools
- Believes open versus closed changes style encapsulation
- Assumes closed mode blocks events from escaping the component
- Cannot say what composedPath does differently for a closed root