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.

Observing Before Insertion, by ObserverA grid of three observers against three situations. For a detached element later inserted, IntersectionObserver reports not intersecting then delivers on insertion, ResizeObserver stays silent then delivers on insertion, and MutationObserver records changes in the detached subtree. For a fragment's children after append, all three keep working because the nodes move. For template content that is cloned, none of the registrations apply to the rendered clones.Detached, then insertedFragment child, appendedTemplate node, then clonedIntersectionObserverfalse first, then realworks, nodes moveclone never observedResizeObserversilent, then deliversworks, nodes moveclone never observedMutationObserverrecords while detachedworksclone never observed

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

TypeScript
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.

TypeScript
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.

From Template to Observed, Rendered NodeFour boxes. The template's content is cloned, producing new nodes. The clone is filled with row data. The clone's image nodes are registered with the lazy observer. The fragment is appended to the list, moving those exact nodes into the document, where the observer delivers real intersections.cloneNode(true)new nodes, not thetemplate'sfill(clone)data into the cloneobserve(clone imgs)the nodes that willrenderappend(fragment)same nodes move in;entries arrive

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:

TypeScript
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.

Keeping the Observed Set Equal to the PageFour steps. Observe everything already matching when starting. On each batch of added nodes, observe matching elements and their matching descendants. On each batch of removed nodes, unobserve them. The observed set then always matches what is actually in the document.1Initial scanobserve() every existing match.2On insertionAdded nodes and their matching descendants are observed.3On removalRemoved nodes and their descendants are unobserved.4InvariantObserved set equals what is actually in the page.

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 innerHTML in a container with autoObserve and confirm new elements are picked up.
  • Check ResizeObserver on a detached element delivers once after insertion.

Common Mistakes to Avoid

  • Observing template.content nodes. Clones are new nodes; observe the clone.
  • Unobserving on the first entry unconditionally. The first entry for a detached target is false.
  • Assuming ResizeObserver will 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.


↑ Back to Observer Lifecycle & Memory Management