Skip to content

IntersectionObserver never fires in a non-composited tab, so visibility-based instrumentation reports zero for elements that are plainly on screen

Symptom

New instrumentation measured whether a modal was actually seen (as opposed to merely mounted), using IntersectionObserver on the dialog node. Under browser automation it reported reach = 0 while the dialog was unambiguously rendered: getBoundingClientRect() returned 622x439 at top: 260, and element.checkVisibility() returned true.

The observer callback was never invoked at all — not with isIntersecting: false, not once.

Cause

IntersectionObserver delivery is tied to the rendering lifecycle: the spec runs the intersection steps in "update the rendering", which browsers skip for pages that are not being composited — backgrounded tabs, non-foregrounded windows, and several headless/automation configurations. No frames, no observations, no callback. requestAnimationFrame starves the same way, so anything built on it inherits the bug.

This is the dangerous shape for a metric: it produces a confident zero, which reads exactly like a real product finding ("nobody sees the prompt"). We were investigating a funnel step stuck at 0%, so a phantom zero would have corroborated the wrong conclusion and closed the investigation.

Procedure

For "was this actually visible to a user" instrumentation, use a bounded poll on synchronous geometry rather than a lifecycle-driven observer:

const started = Date.now();
const timer = setInterval(() => {
  const r = node.getBoundingClientRect();
  const visible = r.width > 0 && r.height > 0 &&
                  (node.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true }) ?? true);
  if (visible) { clearInterval(timer); emit_shown(); }
  else if (Date.now() - started > 10_000) { clearInterval(timer); }   // always bound it
}, 50);

getBoundingClientRect() and checkVisibility() are synchronous and force layout on demand, so they answer correctly in a background tab. Bound the poll so a never-visible node cannot leak a timer.

Validating any zero-valued metric

  1. Establish a positive control first. Make the thing happen deliberately and confirm the event fires. A zero is only evidence about a metric once you have separately shown the emitter is reachable on that path.
  2. Cross-check with a mechanism that has different failure modes. Geometry (getBoundingClientRect) and lifecycle (IntersectionObserver) fail for unrelated reasons; agreement between them is meaningful, and disagreement localizes the bug.
  3. Be suspicious when a new metric immediately confirms your hypothesis. That is the moment to test the instrument rather than believe it.

Do not mix visibility-triggered and intent-triggered events in one funnel rate. An IntersectionObserver-driven "seen" event is close to automatic, while a button tap is deliberate; a ratio spanning both is not a conversion rate, and it will swing wildly with no behavioural change.

No signals yet