Use IntersectionObserver with a 0.5 threshold and a one-second continuity timer to satisfy the IAB/MRC viewability standard without polling the DOM or blocking the main thread.

Problem / Scenario Context

The Media Rating Council (MRC) and IAB define a display ad impression as viewable when at least 50% of its pixels are visible on screen for a minimum of one continuous second. Many teams implement this check by attaching scroll and resize event listeners that call getBoundingClientRect() on every tick. On pages with heavy scroll activity — ad-supported news sites, infinite feeds, dashboards — those synchronous DOM reads force the browser to flush pending style recalculations before every measurement, a pattern described in reducing layout thrashing with ResizeObserver that degrades Interaction to Next Paint (INP) and Core Web Vitals scores under audit conditions.

The secondary failure mode is in nested scroll containers. When an ad unit sits inside a overflow: auto panel rather than the root document, a naively configured observer reports intersection against the viewport, not the panel, so ads scroll out of the panel while the observer still reports them as visible — producing false-positive compliance payloads that do not survive MRC audits. This page is a companion to Dynamic Visibility Tracking and addresses the exact measurement accuracy and lifecycle correctness problems that arise in production ad placements.

Mechanics Explanation

IntersectionObserver callbacks are delivered asynchronously after the browser completes layout and paint for a given frame. The callback receives an array of IntersectionObserverEntry objects; each entry carries intersectionRatio (the fraction of the target currently clipped by the root rectangle) and isIntersecting (a boolean shortcut). The browser guarantees that the callback fires at least once with the current state when observe() is called, which means you get an initial reading without any scroll event.

The compliance problem has two independent parts:

  1. Threshold crossing vs. sustained visibility. A threshold of 0.5 tells the observer to fire whenever the ratio crosses 50% — either entering or leaving. That single callback cannot prove the ad was continuously visible for a full second; you must start a timer on entry and cancel it on exit.
  2. Root scope. The root option defaults to null, which maps to the viewport's layout rectangle. Any ad inside an overflow scroll ancestor is measured against the wrong rectangle unless you pass the ancestor element as root.

The browser delivers IntersectionObserver callbacks in the same task queue position as requestAnimationFrame — after layout, before paint — so they are frame-accurate and never cause forced synchronous layout, unlike getBoundingClientRect() called inside a scroll handler. The IntersectionObserver threshold array controls exactly which ratio values trigger callbacks; setting multiple thresholds (0, 0.25, 0.5, 0.75, 1.0) gives you fine-grained progress data without polling.

Timing State Machine Diagram

The MRC compliance check is a small state machine. The SVG below shows the states and the events that move between them.

MRC Ad Viewability State Machine Five states: Idle, Watching, Timing, Compliant, and Paused. Arrows show transitions triggered by observer callbacks and Page Visibility API events. Idle Watching Timing Compliant Paused observe() ratio ≥ 0.5 1 000 ms ratio < 0.5 → reset timer tab hidden tab visible

Comparison Table

Approach Layout thrash Root-scope aware Continuous timing Duplicate prevention Tab-hidden guard
scroll + getBoundingClientRect() Yes — every frame No Manual Manual No
IntersectionObserver (bare, no timer) No Configurable No No No
IntersectionObserver + timer (this page) No Explicit root Yes (setTimeout) hasFired flag + disconnect() visibilitychange listener

Minimal Reproducible Example

This 28-line snippet isolates the compliance timing logic. Paste it into DevTools Console on any page with a .ad-unit element to see how the state machine behaves.

TypeScript
// Minimal MRC compliance check — TypeScript
// Plain JS: remove `: HTMLElement | null`, `: number`, and the `interface` block.

interface CompliancePayload {
  adId: string;
  ratio: number;
  timestamp: number;
}

const ad = document.querySelector<HTMLElement>('.ad-unit');
let timer: number | null = null;
let hasFired = false;

const observer = new IntersectionObserver((entries) => {
  const entry = entries[0];
  if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && !hasFired) {
    timer = setTimeout(() => {
      hasFired = true;
      observer.disconnect();
      const payload: CompliancePayload = {
        adId: ad?.dataset.id ?? 'unknown',
        ratio: entry.intersectionRatio,
        timestamp: Date.now(),
      };
      console.log('MRC viewable', payload); // replace with analytics.track()
    }, 1000);
  } else if (!entry.isIntersecting || entry.intersectionRatio < 0.5) {
    if (timer !== null) { clearTimeout(timer); timer = null; }
  }
}, { threshold: [0, 0.5, 1.0] });

if (ad) observer.observe(ad);

Production-Safe Solution

The class below adds the remaining compliance requirements: explicit root for nested scroll containers, background-tab pause/resume using the Page Visibility API, and deterministic teardown for framework lifecycle hooks.

TypeScript
interface TrackerOptions {
  /** The scroll container that clips the ad; null = viewport. */
  root: Element | null;
  /** Called once when MRC viewability is confirmed. */
  onCompliant: (payload: CompliancePayload) => void;
}

export class MRCAdTracker {
  private observer: IntersectionObserver;
  private timer: number | null = null;
  private timerStart: number | null = null;
  private elapsed = 0;
  private hasFired = false;
  private isVisible = false;

  constructor(
    private readonly ad: HTMLElement,
    private readonly options: TrackerOptions,
  ) {
    // Use the [IntersectionObserver threshold array] to get both entry and exit callbacks.
    this.observer = new IntersectionObserver(
      (entries) => this.handleEntries(entries),
      { root: options.root, rootMargin: '0px', threshold: [0, 0.5] },
    );
    document.addEventListener('visibilitychange', this.handleVisibility);
  }

  start(): void {
    this.observer.observe(this.ad);
  }

  private handleEntries(entries: IntersectionObserverEntry[]): void {
    if (this.hasFired) return;
    const entry = entries[entries.length - 1]; // latest entry wins when coalesced
    if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
      this.isVisible = true;
      this.startTimer();
    } else {
      this.isVisible = false;
      this.pauseTimer();
    }
  }

  private startTimer(): void {
    if (this.timer !== null) return; // already running
    this.timerStart = Date.now();
    const remaining = 1000 - this.elapsed;
    this.timer = setTimeout(() => {
      if (!this.hasFired && this.isVisible) {
        this.hasFired = true;
        this.options.onCompliant({
          adId: this.ad.dataset.id ?? '',
          ratio: 0.5, // threshold met
          timestamp: Date.now(),
        });
        this.destroy();
      }
    }, remaining);
  }

  private pauseTimer(): void {
    if (this.timer === null) return;
    this.elapsed += Date.now() - (this.timerStart ?? Date.now());
    clearTimeout(this.timer);
    this.timer = null;
    this.timerStart = null;
  }

  private handleVisibility = (): void => {
    if (document.visibilityState === 'hidden') {
      this.pauseTimer();
    } else if (this.isVisible) {
      // Resume only if the ad was still on screen when the tab was hidden.
      this.startTimer();
    }
  };

  destroy(): void {
    document.removeEventListener('visibilitychange', this.handleVisibility);
    this.pauseTimer();
    this.observer.disconnect();
  }
}

// Usage in React:
// useEffect(() => {
//   if (!adRef.current) return;
//   const tracker = new MRCAdTracker(adRef.current, { root: null, onCompliant: analytics.track });
//   tracker.start();
//   return () => tracker.destroy();
// }, []);

Key decisions in this implementation:

  • entries[entries.length - 1] — when the browser coalesces multiple intersection events into a single callback, the last entry carries the most recent state. Processing only the final entry prevents spurious timer resets caused by intermediate ratio values.
  • elapsed accumulation — pausing and resuming preserves progress. An ad that is visible for 700 ms, hidden during tab switch, then visible again only needs 300 ms more. The MRC standard requires continuous visibility within a single session, so this approach is intentionally conservative: accumulated time resets if the ad scrolls out of view.
  • destroy() after fire — once the compliance event is sent, the observer disconnects and all references are released. This prevents duplicate payloads and allows garbage collection of the ad node. See preventing memory leaks in long-running observers for the broader pattern.

Edge Cases for Production Compliance

Scenario Root cause Compliance fix
Cross-origin iframe ads Same-origin policy blocks parent DOM queries into the iframe document Use postMessage from inside the iframe to relay IntersectionObserverEntry data to the parent analytics context
Nested overflow: auto panels Observer defaults to viewport root, ignoring inner scroll boundaries Pass the panel element as root in the constructor options
Sticky headers / footers Fixed UI overlays obscure ad pixels; viewport math still counts them visible Apply negative rootMargin (e.g. -60px 0px -40px 0px) to shrink the effective root rectangle
SPA route transitions Component unmounts without cleanup; observer and timer persist Call tracker.destroy() in useEffect cleanup, Vue onUnmounted, or Angular ngOnDestroy
Background tab throttle setTimeout minimum delay increases to 1 000 ms+ in inactive tabs, inflating dwell time The visibilitychange guard in MRCAdTracker pauses the timer while the tab is hidden
SSR / hydration document and window are undefined on the server Wrap instantiation in useEffect / onMounted / ngAfterViewInit; never call observe() during server rendering

Verification Steps

After deploying, confirm correct behaviour with these DevTools steps:

  • Threshold accuracy. Open Console and log the raw IntersectionObserverEntry values. Scroll the ad so exactly half its height is on screen; intersectionRatio should be 0.5 ± a few hundredths (sub-pixel rounding is normal).
  • Timer fires once. Set a breakpoint or console.count() inside onCompliant. Scroll through the ad multiple times; the count should never exceed 1 per ad unit.
  • Timer resets on exit. Scroll the ad into view, wait 700 ms, then scroll it out. No payload should fire. Scroll it back in; the full one-second timer should restart (elapsed counter resets on scroll-out).
  • Tab-hidden guard. Scroll ad into view, switch to another tab for two seconds, switch back. The payload should not fire during the hidden period; the timer should resume on return.
  • Memory cleanup. Open the Memory panel, take a heap snapshot after navigating away from the ad page. Search for MRCAdTracker or IntersectionObserver — no live instances should remain if destroy() was called on unmount.

Common Mistakes to Avoid

  • Not resetting elapsed on scroll-out. Accumulating time across multiple brief sightings is not MRC-compliant. Reset elapsed = 0 whenever isIntersecting drops to false and the ad scrolls away — the standard requires one unbroken second of visibility.
  • Omitting the root option. On pages where the ad container has its own scroll, the default viewport root silently produces wrong intersection ratios. Always pass the nearest scroll ancestor as root or verify that the viewport is genuinely the relevant scroll container.
  • Firing the payload from inside the observer callback synchronously. The callback runs in a rendering microtask boundary; heavy synchronous work here delays paint. Dispatch the analytics call with queueMicrotask() or a minimal setTimeout(fn, 0) to yield to the browser.
  • Skipping disconnect() after firing. Without disconnect, the observer continues to deliver callbacks on every threshold crossing, and a poorly placed hasFired check can still send duplicate payloads if the flag is not set before the async call returns.

↑ Back to Dynamic Visibility Tracking