Native observer APIs have broad support in modern browsers, but production applications still encounter iOS WebViews, embedded webviews in enterprise tools, and legacy Chromium forks that lack full compliance. A well-structured compatibility layer answers one question first: which environments will your users actually run, and what is the minimum API surface you need to keep the product functional for them?

This page is part of the Core Observer Fundamentals & Browser APIs section. It covers the specific problem of shipping observer-dependent code safely across the full browser matrix — from evergreen Chrome to Safari 12 and Android WebView 4 — without sacrificing Time to Interactive on the majority of users running modern engines.


Concept Framing

IntersectionObserver and ResizeObserver are asynchronous, rendering-phase APIs. The browser delivers their callbacks during its own update cycle — after layout, before paint — which is why they avoid the layout thrashing that plagued legacy scroll and resize event listeners. Polyfills cannot replicate this scheduling perfectly; they must approximate it using MutationObserver, requestAnimationFrame, or periodic polling, each with different performance characteristics.

Understanding this gap between native and polyfilled behavior determines which fallback strategy is acceptable for your performance budget. Sites with strict 100ms Time to Interactive requirements and heavy DOM mutation rates need a fundamentally different approach than a simple static marketing page that uses one lazy-loading observer.

Native vs polyfill observer callback timing in the browser rendering pipeline Two horizontal swimlanes showing the browser rendering pipeline. The top lane shows native IntersectionObserver and ResizeObserver callbacks firing between Layout and Paint in the rendering update step. The bottom lane shows polyfill callbacks firing via MutationObserver or rAF, which fall in the macrotask queue before the next rendering update, potentially adding a full frame of latency. Native Polyfill Script Style Layout Observer Callbacks Paint fires here — same frame Script Style Layout Paint Polyfill callback (next frame via rAF)

Spec / Signature Reference Table

The table below captures the constructor signatures, options fields, and entry properties that differ between the two observer APIs — the columns that matter most when selecting or authoring a polyfill.

Property / Option IntersectionObserver ResizeObserver Polyfill coverage notes
Constructor new IntersectionObserver(callback, options?) new ResizeObserver(callback) Both polyfills must match exact callback signature
options.root Element | Document | null N/A Polyfills must support non-viewport roots (e.g. scroll containers)
options.rootMargin CSS margin string N/A Subpixel and percentage values are often mis-handled in polyfills
options.threshold number | number[] N/A Polyfills often only fire at 0/1; multi-threshold arrays need explicit testing
options.trackVisibility boolean (v2, Chromium only) N/A No polyfill; must be gated with 'trackVisibility' in IntersectionObserver.prototype
options.delay number ms (v2, Chromium only) N/A No polyfill; must be gated
Entry property IntersectionObserverEntry ResizeObserverEntry
entry.isIntersecting boolean N/A v1 polyfills: derive from intersectionRatio > 0
entry.intersectionRatio 0.0–1.0 N/A Floating-point precision varies in polyfills
entry.boundingClientRect DOMRectReadOnly N/A Polyfills use getBoundingClientRect() — triggers layout
entry.contentRect N/A DOMRectReadOnly box: 'content-box' baseline
entry.borderBoxSize N/A ResizeObserverSize[] Missing in older polyfills; check before using
entry.contentBoxSize N/A ResizeObserverSize[] Missing in older polyfills
entry.devicePixelContentBoxSize N/A ResizeObserverSize[] Rarely polyfilled; needed for HiDPI canvas sizing

Step-by-Step Implementation

Step 1: Synchronous feature detection

Feature detection must be synchronous and run before any observer instantiation. Do not rely on user-agent strings — they are unreliable in webviews, headless runners, and Electron shells.

TypeScript
// TypeScript — synchronous, no I/O, safe to run at module load time
const support = {
  intersectionObserver: typeof window !== 'undefined' && typeof window.IntersectionObserver === 'function',
  resizeObserver: typeof window !== 'undefined' && typeof window.ResizeObserver === 'function',
  intersectionObserverV2:
    typeof window !== 'undefined' &&
    'IntersectionObserverEntry' in window &&
    'isVisible' in IntersectionObserverEntry.prototype,
};

// Plain JS equivalent:
// var support = {
//   intersectionObserver: typeof window !== 'undefined' && 'IntersectionObserver' in window,
//   resizeObserver: typeof window !== 'undefined' && 'ResizeObserver' in window,
// };

Step 2: Conditional polyfill loading

Load polyfills only when native support is absent. Use dynamic import() to keep them off the critical path entirely. Gate the load on window.addEventListener('load') so the polyfill network request never competes with initial render resources.

TypeScript
// TypeScript
async function loadObserverPolyfills(): Promise<void> {
  const loads: Promise<unknown>[] = [];

  if (!support.intersectionObserver) {
    // ~2–4 KB gzipped; implements the full v1 spec
    loads.push(import('intersection-observer'));
  }

  if (!support.resizeObserver) {
    // ~3–5 KB gzipped; covers contentRect, contentBoxSize, borderBoxSize
    loads.push(import('@juggle/resize-observer').then(({ ResizeObserver }) => {
      (window as any).ResizeObserver = ResizeObserver;
    }));
  }

  await Promise.all(loads);
}

// Gate on load event so polyfill fetch doesn't contend with critical resources
if (document.readyState === 'complete') {
  loadObserverPolyfills();
} else {
  window.addEventListener('load', loadObserverPolyfills);
}

Step 3: rAF coalescing layer

Polyfill callbacks often fire synchronously on every MutationObserver trigger. Without a coalescing layer you can accumulate dozens of spurious measurement cycles per frame. A requestAnimationFrame gate collapses them into one:

TypeScript
// TypeScript — coalescing wrapper compatible with native and polyfilled observers
function makeCoalescingCallback<T>(
  handler: (entries: T[]) => void
): (entries: T[]) => void {
  let pending: T[] = [];
  let rafId: number | null = null;

  return (entries: T[]) => {
    pending.push(...entries);
    if (rafId !== null) return; // already queued
    rafId = requestAnimationFrame(() => {
      handler(pending.splice(0)); // drain and reset
      rafId = null;
    });
  };
}

// Usage with ResizeObserver:
const ro = new ResizeObserver(
  makeCoalescingCallback<ResizeObserverEntry>((entries) => {
    for (const entry of entries) {
      console.log('size:', entry.contentRect.width, entry.contentRect.height);
    }
  })
);

// Plain JS: replace the TypeScript generic with a regular function parameter

Step 4: Cleanup-aware observer factory

Observer lifecycle and memory management requires explicit disconnect() calls. The factory below pairs each observer registration with a teardown token so component unmount routines can clean up deterministically:

TypeScript
interface ObserverHandle {
  disconnect: () => void;
}

function observeElement(
  target: Element,
  type: 'intersection' | 'resize',
  callback: (entries: (IntersectionObserverEntry | ResizeObserverEntry)[]) => void,
  options?: IntersectionObserverInit
): ObserverHandle {
  const wrappedCb = makeCoalescingCallback(callback);

  const observer =
    type === 'resize'
      ? new ResizeObserver(wrappedCb as ResizeObserverCallback)
      : new IntersectionObserver(wrappedCb as IntersectionObserverCallback, options);

  observer.observe(target);

  return {
    disconnect: () => observer.disconnect(),
  };
}

// Plain JS:
// function observeElement(target, type, callback, options) { ... }

Threshold / Configuration Variants

Environment Recommended strategy Why
Evergreen Chrome / Firefox / Safari Native only, no polyfill loaded Full spec compliance, rendering-phase scheduling
iOS 12–14 WebKit IntersectionObserver v1 polyfill rootMargin percentage values may mis-calculate; test on device
Safari < 13.1 ResizeObserver polyfill required ResizeObserver missing entirely
Android WebView < 76 Both polyfills Chromium 76 shipped ResizeObserver; earlier forks lack it
Electron (renderer) Usually native Underlying Chromium version determines support — check process.versions.chrome
Node.js / SSR (no DOM) SSR guard: typeof window !== 'undefined' Neither API exists server-side; guard every instantiation
Cross-origin iframe postMessage bridge + child-frame observer Same-origin policy blocks parent root observation
IntersectionObserver v2 flags No polyfill; graceful-degrade to v1 trackVisibility and delay are Chromium-only; detect with 'isVisible' in IntersectionObserverEntry.prototype

Edge Cases & Gotchas

rootMargin percentage values in polyfills. The spec defines rootMargin percentages relative to the root's bounding box. Several v1 polyfills calculate them relative to the viewport instead, silently breaking scroll-container observers. Always test percentage margins against a scroll container root, not just the implicit viewport root.

intersectionRatio floating-point precision. Native implementations may return 0.9999999 instead of 1.0 when an element is fully visible. Polyfills often snap to exact values. Code like if (entry.intersectionRatio === 1) is fragile in either direction — use entry.isIntersecting && entry.intersectionRatio >= 0.99 instead.

ResizeObserver polyfill and borderBoxSize. The @juggle/resize-observer polyfill supports contentBoxSize and borderBoxSize, but older alternatives (the now-unmaintained resize-observer-polyfill) only expose contentRect. If you depend on border-box dimensions (common for canvas HiDPI scaling), test the polyfill version explicitly.

MutationObserver cascade in polyfills. Heavy DOM mutation during a polyfill-callback pass can trigger another MutationObserver notification, which triggers another polyfill pass — a micro-cascade. The coalescing rAF layer in Step 3 above prevents this by collapsing all entries into a single handler call per frame.

Subpixel layout shifts in iOS 12. On fractional-DPR displays, getBoundingClientRect() returns subpixel values that change between frames even on static elements. Add a Math.round() gate to suppress false-positive resize events:

TypeScript
let lastWidth = 0;
const ro = new ResizeObserver(makeCoalescingCallback<ResizeObserverEntry>((entries) => {
  for (const entry of entries) {
    const w = Math.round(entry.contentRect.width);
    if (w === lastWidth) continue; // suppress subpixel jitter
    lastWidth = w;
    // handle genuine resize
  }
}));

Cross-origin iframe observation. The same-origin policy prevents a parent page from using its own IntersectionObserver to watch elements inside a cross-origin iframe. The iframe must run its own observer and relay results via window.postMessage. The parent reconstructs visibility state from those messages:

TypeScript
// Inside the cross-origin iframe:
const io = new IntersectionObserver((entries) => {
  window.parent.postMessage({ type: 'io', isIntersecting: entries[0].isIntersecting }, '*');
});
io.observe(document.documentElement);

// In the parent:
window.addEventListener('message', (event) => {
  if (event.data?.type === 'io') {
    handleIframeVisibility(event.data.isIntersecting);
  }
});

Framework Integration Patterns

React

Encapsulate the polyfill load in a top-level provider that resolves before any observer hook runs. Once polyfills are loaded, individual hooks can assume native-or-polyfilled APIs are available:

TSX
// TypeScript + React
import { createContext, useContext, useEffect, useRef, useState } from 'react';

const ObserverReadyContext = createContext(false);

export function ObserverPolyfillProvider({ children }: { children: React.ReactNode }) {
  const [ready, setReady] = useState(
    typeof window !== 'undefined' &&
    'IntersectionObserver' in window &&
    'ResizeObserver' in window
  );

  useEffect(() => {
    if (ready) return;
    loadObserverPolyfills().then(() => setReady(true));
  }, []);

  return (
    <ObserverReadyContext.Provider value={ready}>
      {children}
    </ObserverReadyContext.Provider>
  );
}

export function useResizeObserver(
  ref: React.RefObject<Element>,
  callback: ResizeObserverCallback
): void {
  const ready = useContext(ObserverReadyContext);

  useEffect(() => {
    if (!ready || !ref.current) return;
    const handle = observeElement(ref.current, 'resize', callback as any);
    return () => handle.disconnect();
  }, [ready, ref, callback]);
}

Vue 3

A composable isolates observer setup and guarantees cleanup via onUnmounted:

TypeScript
// TypeScript + Vue 3
import { onMounted, onUnmounted, type Ref } from 'vue';

export function useObserverWithPolyfill(
  targetRef: Ref<Element | null>,
  type: 'intersection' | 'resize',
  callback: (entries: (IntersectionObserverEntry | ResizeObserverEntry)[]) => void,
  options?: IntersectionObserverInit
) {
  let handle: ObserverHandle | null = null;

  onMounted(async () => {
    await loadObserverPolyfills();
    if (!targetRef.value) return;
    handle = observeElement(targetRef.value, type, callback, options);
  });

  onUnmounted(() => handle?.disconnect());
}

Angular

A directive encapsulates polyfill loading and lifecycle hooks. Use NgZone.runOutsideAngular to prevent observer callbacks from triggering unnecessary change detection cycles:

TypeScript
// TypeScript + Angular
import { Directive, ElementRef, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core';

@Directive({ selector: '[appResizeObserver]', standalone: true })
export class ResizeObserverDirective implements OnInit, OnDestroy {
  @Output() elementResized = new EventEmitter<ResizeObserverEntry[]>();

  private handle: ObserverHandle | null = null;

  constructor(private el: ElementRef<Element>, private zone: NgZone) {}

  async ngOnInit() {
    await loadObserverPolyfills();
    this.zone.runOutsideAngular(() => {
      this.handle = observeElement(this.el.nativeElement, 'resize', (entries) => {
        this.zone.run(() => this.elementResized.emit(entries as ResizeObserverEntry[]));
      });
    });
  }

  ngOnDestroy() {
    this.handle?.disconnect();
  }
}

Debugging Checklist

Use this checklist when observer callbacks are missing, misfiring, or causing performance regressions in polyfilled environments.

Verify polyfill loaded. Open the Network panel and filter by the polyfill package name. Confirm it was fetched only on browsers that lack native support. If it loads on Chrome 90+, the feature detection condition is incorrect.

Confirm callback signature parity. Log the first argument of your callback. Native IntersectionObserver passes an IntersectionObserverEntry[]; some polyfills wrap entries differently. Check Array.isArray(entries) and entries[0].constructor.name.

Check rootMargin units. Open the Console and run new IntersectionObserver(() => {}, { root: document.querySelector('.scroller'), rootMargin: '10%' }). If it throws, the polyfill does not support percentage margins with a custom root — switch to pixel values.

Profile polyfill overhead. In the Performance panel, record a 3-second trace during rapid DOM mutations. Look for MutationObserver tasks longer than 16ms. If they appear in clusters, the coalescing layer is missing or the polyfill is running layout queries outside rAF.

Reproduction script for polyfill-specific bugs. Temporarily force-load the polyfill by deleting the native constructor before your bootstrap code runs:

JavaScript
// Browser console — force polyfill path for testing
delete window.IntersectionObserver;
delete window.ResizeObserver;
// Then reload or re-run your bootstrap

Heap snapshot for memory validation. Take a snapshot before and after unmounting the component that owns the observer. Filter retained objects by IntersectionObserver or ResizeObserver. Any retained instance signals that disconnect() was not called. See preventing memory leaks in long-running observers for the full teardown pattern.


FAQ

How do I detect IntersectionObserver support without user-agent sniffing?

Test for constructor existence synchronously at module load time: typeof window.IntersectionObserver === 'function'. UA strings change across WebView versions, headless runners, and progressive web apps running in standalone mode. Constructor existence is the only reliable signal.

Does IntersectionObserver v2 work in Safari or Firefox?

No. The trackVisibility and delay options introduced in v2 are implemented only in Chromium-based browsers as of mid-2026. Safari and Firefox support v1. Gate any v2 feature on 'isVisible' in IntersectionObserverEntry.prototype — do not use try/catch around the constructor, which will silently succeed and ignore the unsupported options.

Why does my ResizeObserver polyfill fire on every DOM mutation?

CSS-driven size changes produce no DOM events, so polyfills subscribe to MutationObserver to detect anything that might cause a resize. Every attribute change, child insertion, or class toggle triggers a measurement pass. The coalescing requestAnimationFrame wrapper in Step 3 above collapses these into one pass per frame, matching the native behavior where callbacks fire at most once per rendering update.

Can I observe elements inside a cross-origin iframe?

No. The same-origin policy prevents a parent-frame observer from crossing the frame boundary. The iframe must run its own IntersectionObserver or ResizeObserver and relay measurements to the parent via window.postMessage. The parent reconstructs visibility or size state from those messages. See the cross-origin iframe example in the Edge Cases section above.

How much does a full polyfill add to my bundle?

A full IntersectionObserver polyfill (e.g. intersection-observer from w3c) adds roughly 2–4 KB gzipped. A full ResizeObserver polyfill (e.g. @juggle/resize-observer) adds roughly 3–5 KB gzipped. Both are loaded via dynamic import() gated on feature detection, so modern browsers download zero additional bytes. Only the minority of users on legacy environments pay the network cost.


↑ Back to Core Observer Fundamentals & Browser APIs