You can call observe() on a detached element: IntersectionObserver reports it as not intersecting and then reports again once it is inserted and laid out, ResizeObserver stays silent while it has no box and delivers once it gains one, and MutationObserver watches the detached subtree itself — but elements inside a <template> or a DocumentFragment that are later moved into the page are different nodes, so observe after insertion or observe the inserted node.
Problem / Scenario Context
A virtualised list builds rows off-screen for speed: it creates row elements, fills them, registers each with the lazy-image observer, then appends a batch in one DocumentFragment. Rows render correctly, but some rows' images never load. Another team clones rows from a <template> and registers the template content's nodes with the observer before cloning — none of their images ever load.
The first problem is a race with detached targets; the second is observing nodes that never enter the page. The Observer Lifecycle & Memory Management topic covers the lifecycle; this page covers the moments before insertion.
Mechanics Explanation
IntersectionObserver accepts any Element. For a target that is not connected, or not a descendant of the root, the intersection is computed as "not intersecting". Its initial entry reports isIntersecting: false. When the element is inserted and laid out, the next rendering update computes a real intersection; if that crosses a threshold relative to the recorded "not intersecting" state, an entry is delivered. So detached observation works, but code that treats the initial false entry as final — for example, unobserving elements reported as not visible — will drop them.
ResizeObserver records a last-reported size of 0×0 on observe(). A detached element has no box, so its size is 0×0 and nothing is delivered. When it is inserted and gets a real size, it is delivered. That is the same behaviour as display: none.
MutationObserver does not care about connection: it observes the node and, with subtree, its descendants, wherever they are. Building content off-document under an observed root produces records as usual.
Templates and fragments. Appending a DocumentFragment moves its children into the document, so observers registered on those children keep working. But template.content is a separate, inert document; template.content.cloneNode(true) or document.importNode creates new nodes. Observers registered on the template's own nodes observe nodes that never render.
Comparison Table: When to Call observe()
| Creation pattern | Observe when | Watch out for |
|---|---|---|
createElement → fill → append |
before or after append | initial IO entry is false if observed before |
Build in a DocumentFragment → append fragment |
before or after append | same as above; nodes move with the fragment |
Clone from <template> |
after cloning, on the clone | never observe template.content nodes |
innerHTML / insertAdjacentHTML |
after insertion, query the new nodes | nodes do not exist before parsing |
| Framework render | in the framework's mount hook / ref | hooks run after insertion |
| Custom element | in connectedCallback |
runs on insertion |
Minimal Reproducible Example
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) loadImage(e.target as HTMLImageElement);
io.unobserve(e.target); // BUG: also unobserves the initial "false" entry
}
});
const tpl = document.querySelector<HTMLTemplateElement>('#row')!;
const img = tpl.content.querySelector('img')!;
io.observe(img); // BUG: observes the template's node, not the clone
list.append(tpl.content.cloneNode(true));
declare const list: HTMLElement;
declare function loadImage(img: HTMLImageElement): void;
Rows added this way never load their images: the observed node is inside the inert template, and even when observing detached clones, the unconditional unobserve drops them on their initial false entry.
Production-Safe Solution
Observe the nodes that will actually render, and never treat "not intersecting" as final.
const lazy = new IntersectionObserver((entries, obs) => {
for (const e of entries) {
if (!e.isIntersecting) continue; // not yet: keep observing
loadImage(e.target as HTMLImageElement);
obs.unobserve(e.target); // unobserve only after the work is done
}
}, { rootMargin: '300px 0px' });
export function appendRows(list: HTMLElement, tpl: HTMLTemplateElement, rows: RowData[]): void {
const frag = document.createDocumentFragment();
for (const r of rows) {
const clone = tpl.content.cloneNode(true) as DocumentFragment; // new nodes
fill(clone, r);
// Observe the clone's nodes — they move into the document with the fragment.
clone.querySelectorAll<HTMLImageElement>('img[data-src]').forEach((img) => lazy.observe(img));
frag.append(clone);
}
list.append(frag); // nodes move; observations stay valid
}
interface RowData { title: string; image: string }
declare function fill(f: DocumentFragment, r: RowData): void;
Observing before insertion is fine here because the callback ignores the initial false entries and keeps waiting. Observing after insertion is equally fine and avoids that initial entry altogether; pick whichever is more convenient in your code, and keep the callback tolerant of both.
Reliable Wiring With MutationObserver
When you do not control insertion — content arriving from a CMS embed, a third-party widget or server-streamed HTML — a MutationObserver on the container can register new elements with other observers as they are inserted:
export function autoObserve(container: Element, selector: string, target: IntersectionObserver): () => void {
container.querySelectorAll(selector).forEach((el) => target.observe(el));
const mo = new MutationObserver((records) => {
for (const r of records) {
r.addedNodes.forEach((n) => {
if (n.nodeType !== Node.ELEMENT_NODE) return;
const el = n as Element;
if (el.matches(selector)) target.observe(el);
el.querySelectorAll(selector).forEach((m) => target.observe(m));
});
r.removedNodes.forEach((n) => {
if (n.nodeType !== Node.ELEMENT_NODE) return;
const el = n as Element;
if (el.matches(selector)) target.unobserve(el);
el.querySelectorAll(selector).forEach((m) => target.unobserve(m));
});
}
});
mo.observe(container, { childList: true, subtree: true });
return () => mo.disconnect();
}
Registering on insertion and unregistering on removal keeps the observed set exactly equal to what is in the page — no detached elements retained, no rendered elements missed. The scaling concerns of this pattern on busy pages are covered in MutationObserver performance with subtree: true.
Verification Steps
- Append rows from a template and confirm each row's image loads when scrolled into view.
- Log the first entry for elements observed before insertion and confirm the callback does not drop them.
- Remove rows and confirm they are unobserved (a counter, or detached nodes in a heap snapshot).
- Insert content via
innerHTMLin a container withautoObserveand confirm new elements are picked up. - Check
ResizeObserveron a detached element delivers once after insertion.
Common Mistakes to Avoid
- Observing
template.contentnodes. Clones are new nodes; observe the clone. - Unobserving on the first entry unconditionally. The first entry for a detached target is
false. - Assuming
ResizeObserverwill report a detached element. It has no box until inserted. - Registering on insertion but not unregistering on removal. Removed elements stay observed and retained.
FAQ
Can I call observe on an element that is not in the document?
Yes, for all three observers. IntersectionObserver reports it as not intersecting until inserted, ResizeObserver waits until it has a box, and MutationObserver observes the detached subtree normally.
Do observations survive appending a DocumentFragment?
Yes. Appending a fragment moves its child nodes into the document; they are the same nodes, so observers registered on them keep working.
Why do nodes cloned from a template not trigger my observer?
Because cloning creates new nodes. An observer registered on the template content's nodes is watching nodes that stay inside the inert template and never render.
Does observing a detached element leak memory?
It can. The observer holds the element strongly while it is observed, so a detached element that is never inserted and never unobserved stays alive as long as the observer does.
What about elements inside an iframe that has not loaded yet?
Elements belong to the iframe's document, which does not exist until the iframe loads. Observe from within the iframe after its load event, or observe the iframe element itself from the parent.
Related
- Observer Callback Not Called on Initial Render — when the first entry never comes
- Unobserve vs Disconnect: When to Use Each — releasing removed elements
- Recycling DOM Nodes in Observer-Driven Lists — nodes that are reused rather than recreated
↑ Back to Observer Lifecycle & Memory Management