Version 1 answers a geometric question: is this element's rectangle overlapping the root's rectangle. Version 2 adds a rendering question: was it actually painted, unobstructed. The difference is what separates an element being in view from being seen.

Problem / Scenario Context

An impression is recorded when a promoted card reaches fifty per cent visibility for one second. The implementation is correct by version 1's definition and still over-counts, because the ratio says nothing about four situations that all occur regularly:

  • A modal overlay is covering the card.
  • The card's opacity is zero because a fade-in never completed.
  • An ancestor has a filter or transform that makes the content unreadable.
  • A cross-origin overlay has been positioned on top of it.

Each of these leaves the geometry untouched. Version 1 cannot see any of them, which is why analytics built on it are systematically generous, and why the dwell-and-verify approach has to reach for other signals.

Mechanics Explanation

Version 2 introduces two options and one entry field.

trackVisibility: true asks the browser to compute, in addition to the intersection, whether the element was actually rendered. delay — required, and at least 100 milliseconds — throttles how often that computation runs, because it is genuinely more expensive than the geometric test. entry.isVisible then reports the result.

The check the browser performs is deliberately conservative. It returns false unless it can cheaply prove the element was painted unobstructed, which means several harmless situations also report false: any effect it cannot cheaply reason about, certain compositing arrangements, and — importantly — some cases that differ between engines.

That conservatism is the design's core trade-off. isVisible === true is a strong signal you can trust. isVisible === false is weak: it may mean occluded, or it may mean "the browser declined to decide". Treating the false case as proof of occlusion produces under-counting to replace the over-counting you were fixing.

Four Situations, Two API VersionsFour states of the same card compared across the two API versions. Fully visible is reported as intersecting by both, and visible by version two. Covered by a modal, zero opacity and behind a heavy filter are all reported as intersecting by version one, while version two reports them as not visible. A note records that version two also reports false in cases it simply cannot decide.Situationv1 isIntersectingv2 isVisibleFully on screen, unobstructedtruetrueCovered by a modal overlaytruefalseopacity: 0truefalseAncestor filter or transformtruefalse…and also false when the browser simply declines to decide. True is trustworthy; false is not proof of occlusion.

Comparison Table: Configuring v2

Option Requirement Effect
trackVisibility: true must be paired with delay populates entry.isVisible
delay ≥ 100 ms, or construction throws minimum interval between visibility computations
threshold unchanged still governs when entries are delivered
rootMargin unchanged still expands the root box
Feature test 'isVisible' in IntersectionObserverEntry.prototype detects support without constructing

The delay requirement is not advisory. Constructing with trackVisibility: true and no delay, or a delay below the minimum, throws a TypeError, which is a common cause of an observer that appears never to be created.

Minimal Reproducible Example

TypeScript
// Throws: trackVisibility requires a delay of at least 100
new IntersectionObserver(cb, { trackVisibility: true });
// TypeError: Failed to construct 'IntersectionObserver':
// delay must be set to at least 100 for trackVisibility to be enabled.

Production-Safe Solution

Treat v2 as an upgrade to the confidence of a positive result, never as a hard gate:

TypeScript
const SUPPORTS_V2 = 'isVisible' in IntersectionObserverEntry.prototype;

interface Options extends IntersectionObserverInit {
  trackVisibility?: boolean;
  delay?: number;
}

function makeImpressionObserver(onQualified: (el: Element, confident: boolean) => void) {
  const opts: Options = { threshold: 0.5 };
  if (SUPPORTS_V2) { opts.trackVisibility = true; opts.delay = 250; }

  return new IntersectionObserver((entries) => {
    for (const entry of entries) {
      const geometric = entry.isIntersecting && entry.intersectionRatio >= 0.5;
      if (!geometric) { cancelDwell(entry.target); continue; }

      // isVisible true is strong evidence; false is not proof of occlusion
      const confident = SUPPORTS_V2 ? (entry as IntersectionObserverEntry & { isVisible: boolean }).isVisible : false;
      startDwell(entry.target, 1000, () => onQualified(entry.target, confident));
    }
  }, opts);
}

The confident flag is what makes this usable. Recording it alongside the impression lets the downstream system distinguish "definitely seen" from "geometrically qualified, visibility unverified", and lets the two be reconciled later without discarding data:

TypeScript
onQualified(el, confident) {
  beacon('/impression', { id: el.id, verified: confident });
}

Keep the other checks regardless, because they cover cases v2 does not and are cheap:

TypeScript
const reallyVisible = document.visibilityState === 'visible'
  && el.getBoundingClientRect().height > 0
  && Number(getComputedStyle(el).opacity) > 0.1;

The delay value deserves a moment's thought. It is a floor on how often the browser computes visibility, so a larger value costs less and reacts more slowly. For a one-second dwell requirement, 250 ms is a reasonable balance: four samples across the dwell window, at a quarter of the cost of the minimum.

Treating isVisible as Confidence, Not as a GateA decision path from a geometrically qualified impression. If isVisible is true the impression is recorded as verified. If it is false, or the browser does not support version two, the impression is still recorded but flagged as unverified, so downstream analysis can separate the two populations rather than discarding either.geometry qualifiedratio ≥ 0.5 for 1sisVisible truerecord as verifiedpainted, unobstructed — the browser is confident and so are youfalse / unsupportedrecord as unverifiedstill an impression — the flag lets analysis separate the populationsDiscarding the false branch replaces over-counting with under-counting

A note on the delay budget

The delay value is a floor on how often the browser recomputes visibility, and it interacts with whatever dwell requirement sits on top of it. A one-second dwell sampled every 250 milliseconds gives four opportunities to observe an occlusion appearing or disappearing during the qualifying window, which is enough resolution to catch a modal that opens halfway through. Raising the delay to 500 halves the cost and halves that resolution; lowering it to the 100-millisecond minimum buys resolution that a one-second rule cannot use. Choosing it deliberately, rather than accepting the minimum because it is the minimum, is the difference between paying for information and paying for nothing.

Verification Steps

  • Feature-test before constructing: 'isVisible' in IntersectionObserverEntry.prototype avoids a TypeError on engines without v2.
  • Prove the positive case by observing an unobstructed card and confirming isVisible is true.
  • Prove the occlusion case by opening a modal over it and confirming the flag flips.
  • Check the false-negative rate by logging how often geometry qualifies while isVisible is false on a page with no overlays at all; a high rate means the browser is declining rather than detecting.
  • Compare verified and unverified counts in the field before making any decision that depends on the distinction.

Recording Confidence Alongside the EventAn impression payload carrying a verified flag derived from isVisible. Downstream analysis can then report a conservative count using only verified events, a permissive count using all of them, and the ratio between the two as a measure of how much occlusion the page has.One extra boolean turns a lost signal into three usable numbers{ id: "promo-3", qualified: true, verified: false }conservative countverified events only — a floor you can defendpermissive count, and the ratioall qualified events, plus verified ÷ qualified as an occlusion rate for the page

Common Mistakes to Avoid

  • Omitting delay. Construction throws, and the failure looks like the observer was never created.
  • Gating impressions on isVisible === true. Trades over-counting for under-counting, because false is not proof.
  • Assuming uniform behaviour across engines. Support and conservatism both differ; the feature test tells you about the former, not the latter.
  • Dropping the v1 checks. Tab visibility, zero height and computed opacity remain worth checking and are far cheaper.

FAQ

What does isVisible add over isIntersecting?

isIntersecting is purely geometric: the target's rectangle overlaps the root's. isVisible additionally asserts that the element was actually painted without being obscured, which catches modal overlays, zero opacity and certain filters that leave the geometry entirely unchanged.

Why does constructing the observer throw?

Because trackVisibility requires a delay of at least one hundred milliseconds and it was omitted or set too low. The visibility computation is more expensive than the geometric test, so the specification forces you to state how often you are willing to pay for it.

Can I treat isVisible false as proof the element was hidden?

No. The check is deliberately conservative and returns false whenever the browser cannot cheaply prove the element was painted unobstructed, which includes situations that are perfectly visible to the reader. Use true as strong evidence and treat false as unverified rather than as occluded.

Should I stop doing the other visibility checks?

No. Document visibility state, a non-zero bounding rectangle and computed opacity are all cheap, work everywhere, and cover cases version two does not. Version two raises your confidence in a positive result; it does not replace the rest of the gate.


↑ Back to Browser Compatibility & Polyfills