Detect each observer capability separately — the constructor with typeof globalThis.X === 'function', entry fields by checking the prototype, and options by constructing and observing inside try/catch — because browsers have shipped these APIs in stages, and SSR has none of them.

Problem / Scenario Context

A component library checks if ('IntersectionObserver' in window) before using the API. It crashes in three places: during server-side rendering, where window does not exist; inside an old Android webview embedded in a partner's app, which has the constructor but no isIntersecting on entries; and in Safari, where the component passes { box: 'device-pixel-content-box' } to ResizeObserver.observe() and the call throws. Each fix so far has been a one-off patch.

Observer support is not a single yes/no. The Browser Compatibility & Polyfills topic lists what shipped when; this page is about testing for it robustly.

Mechanics Explanation

Feature detection has to answer three different questions, and each needs a different technique:

  1. Does the API exist? A global constructor check. On the server, in workers, and in some sandboxed contexts, window is undefined; globalThis always exists. Use typeof globalThis.IntersectionObserver === 'function'.
  2. Does it have a newer field or method? Check the prototype, not an instance: 'isIntersecting' in IntersectionObserverEntry.prototype. For fields that are only on entries you cannot construct yourself, prototype checks are the only option.
  3. Does it accept a newer option? Browsers validate options in different ways. Unknown dictionary members are ignored (so new IntersectionObserver(cb, { trackVisibility: true }) succeeds even where unsupported), while invalid enum values throw (observe(el, { box: 'device-pixel-content-box' }) throws where the value is unknown). Ignored options need a read-back check; throwing options need try/catch.

A good detection module runs each check once, lazily, and exposes plain booleans.

Detection Technique by FeatureA grid of observer features and how to detect them. The constructors are detected with a typeof check on globalThis. Entry fields like isIntersecting and the box size arrays are detected on the entry prototype. The ResizeObserver box option throws on unknown values and needs try and catch. The trackVisibility option is silently ignored and needs a read-back of the observer's property.TechniqueFailure mode if unsupportedIntersectionObserver ctortypeof globalThisundefinedResizeObserver ctortypeof globalThisundefinedentry.isIntersectingprototype checkundefined fieldcontentBoxSize arraysprototype checkundefined fieldbox option valuetry / catch observethrows TypeErrortrackVisibility optionread back propertysilently ignored

Comparison Table: Common Checks and Their Pitfalls

Check Works in SSR? Catches partial support? Problem
'IntersectionObserver' in window no — throws no window undefined on server
typeof IntersectionObserver !== 'undefined' yes no constructor only
typeof globalThis.IntersectionObserver === 'function' yes no constructor only — combine with field checks
'isIntersecting' in IntersectionObserverEntry.prototype needs a guard yes entry type must exist first
try { ro.observe(el, { box }) } catch {} n/a yes needs an element; run lazily
new IntersectionObserver(cb, opts).trackVisibility n/a yes read back after construction

Minimal Reproducible Example

TypeScript
// Breaks in three environments.
if ('IntersectionObserver' in window) {                              // SSR: ReferenceError
  new IntersectionObserver(([e]) => {
    if (e.isIntersecting) load();                                    // old webview: always undefined
  }).observe(el);
}
new ResizeObserver(draw).observe(canvas, { box: 'device-pixel-content-box' });   // throws where unsupported

declare const el: Element; declare const canvas: HTMLCanvasElement;
declare function load(): void; declare function draw(): void;

Production-Safe Solution

TypeScript
// observer-support.ts — each check runs once, on first use, and never throws.
const g = globalThis as typeof globalThis & Record<string, unknown>;

function once<T>(fn: () => T): () => T {
  let done = false, value: T;
  return () => (done ? value : ((done = true), (value = fn())));
}

export const support = {
  intersection: once(() => typeof g.IntersectionObserver === 'function'),
  resize: once(() => typeof g.ResizeObserver === 'function'),
  mutation: once(() => typeof g.MutationObserver === 'function'),

  isIntersecting: once(() =>
    typeof g.IntersectionObserverEntry === 'function' &&
    'isIntersecting' in (g.IntersectionObserverEntry as { prototype: object }).prototype),

  boxSizes: once(() =>
    typeof g.ResizeObserverEntry === 'function' &&
    'contentBoxSize' in (g.ResizeObserverEntry as { prototype: object }).prototype),

  devicePixelBox: once(() => {
    if (typeof g.ResizeObserver !== 'function' || typeof document === 'undefined') return false;
    const probe = document.createElement('div');
    const ro = new ResizeObserver(() => {});
    try { ro.observe(probe, { box: 'device-pixel-content-box' }); return true; }
    catch { return false; }
    finally { ro.disconnect(); }
  }),

  trackVisibility: once(() => {
    if (typeof g.IntersectionObserver !== 'function') return false;
    const io = new IntersectionObserver(() => {}, { trackVisibility: true, delay: 100 } as IntersectionObserverInit);
    const ok = (io as unknown as { trackVisibility?: boolean }).trackVisibility === true;
    io.disconnect();
    return ok;
  }),
};

// Usage: every entry check goes through a helper that works with partial support.
export const intersecting = (e: IntersectionObserverEntry): boolean =>
  support.isIntersecting() ? e.isIntersecting : e.intersectionRatio > 0;

Consumers ask for exactly the capability they need and choose a fallback per feature: no observer at all → load eagerly; no isIntersecting → ratio check; no device-pixel box → round the CSS size times devicePixelRatio; no trackVisibility → count plain intersection. None of the checks touch window at import time, so the module is safe to import during SSR.

Fallback per Missing CapabilityA decision chain for choosing fallbacks. If the IntersectionObserver constructor is missing, load content eagerly or use a polyfill. Otherwise, if isIntersecting is missing, fall back to an intersectionRatio greater than zero check. Otherwise, if the device-pixel box option is missing, round the CSS size multiplied by devicePixelRatio. Otherwise, if trackVisibility is missing, use plain intersection and treat occlusion as unknown.No IntersectionObserver constructor?Load eagerly, or polyfill where it mattersyesnoNo isIntersecting on entries?Use intersectionRatio > 0yesnoNo device-pixel-content-box?Round CSS size × devicePixelRatioyesnoNo trackVisibility?Plain intersection; occlusion unknownyesnoEverything available: use the full-featured path.

Detecting Inside Frameworks and Tests

Detection code tends to run in more environments than the observers themselves: server renderers, test runners, storybooks, web workers. A few rules keep it portable:

  • Never detect at module top level with side effects. Constructing a probe observer at import time runs on the server, in every test file, and before document.body exists. The lazy once wrapper above defers every check to first use.
  • Treat JSDOM as "unsupported". JSDOM has no observers; the checks correctly return false, and components take their fallback path unless the test installs a mock — see mocking IntersectionObserver in Jest and Vitest.
  • Do not cache across realms. An iframe has its own globals. Code that runs in both the parent and an iframe should detect in each.
  • Log what you detect. Sending the support booleans with your field telemetry tells you how many users actually take each fallback — often zero, which justifies deleting it.

Where Detection Runs and What It ReturnsFour boxes. On the server there is no window and every check returns false without throwing. In JSDOM tests the checks return false unless a mock is installed. In a modern browser every check returns true. In an old webview the constructor exists but field checks return false, so helpers take the fallback path.Server renderall false, no throwJSDOM testfalse unless mockedModern browserall trueOld webviewctor yes, fields no

Verification Steps

  • Import the detection module in your SSR build and confirm nothing throws.
  • Run the checks in each supported browser and record the results; compare with your support matrix.
  • Force each fallback by stubbing the relevant check to false in a test and confirm the component still works.
  • Send the booleans with field telemetry and look at the real distribution.
  • Run in a worker context if any shared code might, and confirm no document access happens.

Common Mistakes to Avoid

  • 'X' in window checks. They throw during SSR.
  • Checking only the constructor. Partial implementations exist, and fields ship later than constructors.
  • Assuming unknown options throw. Most are silently ignored; read them back.
  • Detecting at import time. It runs in environments without a DOM and slows every import.

FAQ

Is it still necessary to feature-detect IntersectionObserver?

For the constructor in modern browsers, rarely. It is still necessary for server rendering, test environments, embedded webviews and for newer options and fields, which is where most real breakage comes from today.

Why check the prototype rather than an entry?

Because you cannot create a real entry without observing something and waiting for a callback. The prototype check is synchronous and tells you whether entries will have the field.

Why does passing an unknown box value throw but unknown options do not?

box is an enumeration, and invalid enum values are type errors in WebIDL. Unknown dictionary members are simply ignored, so unsupported options pass silently — which is why they need a read-back check.

Should I polyfill instead of detecting?

Polyfill when the feature is essential and the polyfill is accurate enough, as with ResizeObserver for legacy browsers. Detect and degrade when the feature is an enhancement, or when a polyfill cannot replicate it — trackVisibility, for example, cannot be polyfilled.

Is globalThis safe to use everywhere?

It is supported in every current browser, in Node and in workers. Only very old browsers lack it, and those also lack the observers, so the checks return false through the typeof guard.


↑ Back to Browser Compatibility & Polyfills for Observer APIs