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:
- Does the API exist? A global constructor check. On the server, in workers, and in some sandboxed contexts,
windowis undefined;globalThisalways exists. Usetypeof globalThis.IntersectionObserver === 'function'. - 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. - 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 needtry/catch.
A good detection module runs each check once, lazily, and exposes plain booleans.
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
// 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
// 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.
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.bodyexists. The lazyoncewrapper 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.
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
falsein 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
documentaccess happens.
Common Mistakes to Avoid
'X' in windowchecks. 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.
Related
- Polyfilling ResizeObserver for Legacy Browsers — when detection says no
- ResizeObserver Box Options Browser Support — the box option in detail
- Fixing "IntersectionObserver is not defined" in Next.js — the SSR failure mode
↑ Back to Browser Compatibility & Polyfills for Observer APIs