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
filterortransformthat 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.
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
// 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:
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:
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:
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.
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.prototypeavoids aTypeErroron engines without v2. - Prove the positive case by observing an unobstructed card and confirming
isVisibleistrue. - 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
isVisibleisfalseon 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.
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.
Related
- Browser Compatibility & Polyfills — feature detection rather than user-agent testing
- Dynamic Visibility Tracking — the other four reasons a visible element was not seen
- Tracking Ad Visibility for Analytics Compliance — the dwell rules this signal feeds
↑ Back to Browser Compatibility & Polyfills