ResizeObserver is unavailable in IE11, Safari 12, and Chrome 63 and older — add a conditional polyfill and a requestAnimationFrame polling fallback so your component never throws ResizeObserver is not defined in those environments.
Problem / Scenario Context
IE11, Safari 12 and Chrome 63 ship without ResizeObserver. Any component that calls new ResizeObserver(callback) unconditionally crashes in those environments with Uncaught ReferenceError: ResizeObserver is not defined, halting hydration and breaking downstream state initialisation for the entire component tree.
The situation is covered in depth in the Browser Compatibility & Polyfills overview, which catalogues support matrices for all three Observer APIs. This page focuses on the concrete implementation: how to detect the gap at runtime, load fill code only when needed, and wire a fallback polling loop that behaves like the native API without introducing the layout thrashing that unguarded window.resize listeners cause. The observer lifecycle and memory management discipline that applies to native observers is equally important in the polyfill path — a forgotten cancelAnimationFrame call is as costly as a forgotten disconnect().
Mechanics: Why the Gap Exists
The native ResizeObserver implementation lives inside the browser's rendering engine. When an observed element's layout box changes, the engine queues a ResizeObserverEntry notification after the layout phase and delivers it as part of the same task, before paint. This means callbacks fire asynchronously in a batched microtask, never during a forced synchronous layout.
Legacy browsers expose no equivalent scheduling hook. The only generic alternative is requestAnimationFrame, which fires at the start of each frame — approximately every 16 ms at 60 fps. A rAF loop can poll getBoundingClientRect() and compare dimensions across frames, but it introduces per-frame measurement overhead that the native API avoids entirely through engine-level integration. The polyfill is therefore a progressive-enhancement shim, not a zero-cost substitute.
Modern bundlers compound the problem: tree-shaking can silently remove a typeof guard when the guard's branch is considered dead code at build time. The polyfill must be loaded and initialised before any framework hydration code calls new ResizeObserver(), which demands careful attention to module evaluation order or a runtime-checked dynamic import.
Comparison: Native vs. rAF Fallback Behaviour
| Characteristic | Native ResizeObserver |
rAF polling fallback |
|---|---|---|
| Callback timing | After layout, before paint (within same browser task) | Start of next animation frame (~16 ms cadence) |
| Measurement method | Engine-internal layout box read | getBoundingClientRect() on every frame |
| Forced synchronous layout | None | One per observed target per frame |
| Display: none elements | No callback fired | Returns zero rect; must be guarded manually |
| Cross-origin iframe | Supported with engine permissions | Blocked by same-origin policy; must catch |
box option support |
content-box, border-box, device-pixel-content-box |
Approximates border-box only |
| Cleanup API | unobserve() / disconnect() |
cancelAnimationFrame() + manual tracking |
Minimal Reproducible Example
The following snippet demonstrates the failure and the minimal guard needed to isolate it:
// Reproduces the crash in IE11 / Safari 12
// ❌ Unconditional instantiation — throws in unsupported environments
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log(entry.contentRect.width);
}
});
ro.observe(document.querySelector('#box')!);
// ✅ Minimal guard
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log(entry.contentRect.width);
}
});
ro.observe(document.querySelector('#box')!);
} else {
console.warn('ResizeObserver not supported; falling back to rAF polling');
}
// Plain JS equivalent (no TypeScript)
if (typeof ResizeObserver !== 'undefined') {
var ro = new ResizeObserver(function(entries) {
entries.forEach(function(entry) { console.log(entry.contentRect.width); });
});
ro.observe(document.querySelector('#box'));
}
The Polyfill Decision Point: SVG Flow
The diagram below shows when and how the polyfill decision branches at runtime.
Production-Safe Solution
The wrapper below routes to the native API when available and to a rAF polling loop otherwise. Both code paths share the same public interface (observe, unobserve, disconnect), so callers never need to branch on environment.
type ResizeCallback = (entries: Array<{ target: Element; contentRect: DOMRectReadOnly | DOMRect }>) => void;
// Production-safe ResizeObserver wrapper
// Routes to native API when available, rAF polling loop otherwise.
class SafeResizeObserver {
private observer: ResizeObserver | null = null;
private readonly callback: ResizeCallback;
private readonly targets = new Set<Element>();
// WeakMap: target → rAF id; allows GC if target is removed externally
private readonly rafIds = new WeakMap<Element, number>();
constructor(callback: ResizeCallback) {
this.callback = callback;
if (typeof ResizeObserver !== 'undefined') {
// Native path: zero polling overhead
this.observer = new ResizeObserver(callback as ResizeObserverCallback);
}
// Polyfill path initialised lazily in observe()
}
observe(target: Element): void {
if (!target || this.targets.has(target)) return;
this.targets.add(target);
if (this.observer) {
// Native: request border-box dimensions for consistency with fallback
this.observer.observe(target, { box: 'border-box' });
} else {
this._rafFallback(target);
}
}
unobserve(target: Element): void {
this.targets.delete(target);
const id = this.rafIds.get(target);
if (id !== undefined) {
cancelAnimationFrame(id);
this.rafIds.delete(target);
}
this.observer?.unobserve(target);
}
disconnect(): void {
this.observer?.disconnect();
for (const target of this.targets) {
const id = this.rafIds.get(target);
if (id !== undefined) cancelAnimationFrame(id);
}
this.targets.clear();
}
private _rafFallback(target: Element): void {
// Store last known size to fire callback only on actual change
let lastWidth = -1;
let lastHeight = -1;
const poll = (): void => {
// Guard: stop if target was removed from the DOM
if (!document.contains(target)) return;
// Guard: skip hidden elements to avoid false-positive zero callbacks
const rect = target.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
this.rafIds.set(target, requestAnimationFrame(poll));
return;
}
if (rect.width !== lastWidth || rect.height !== lastHeight) {
lastWidth = rect.width;
lastHeight = rect.height;
this.callback([{ target, contentRect: rect }]);
}
this.rafIds.set(target, requestAnimationFrame(poll));
};
this.rafIds.set(target, requestAnimationFrame(poll));
}
}
// Plain JS — same logic without type annotations
class SafeResizeObserver {
constructor(callback) {
this.callback = callback;
this.observer = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(callback) : null;
this.targets = new Set();
this.rafIds = new WeakMap();
}
observe(target) {
if (!target || this.targets.has(target)) return;
this.targets.add(target);
if (this.observer) {
this.observer.observe(target, { box: 'border-box' });
} else {
this._rafFallback(target);
}
}
unobserve(target) {
this.targets.delete(target);
var id = this.rafIds.get(target);
if (id !== undefined) { cancelAnimationFrame(id); this.rafIds.delete(target); }
if (this.observer) this.observer.unobserve(target);
}
disconnect() {
if (this.observer) this.observer.disconnect();
this.targets.forEach(function(t) {
var id = this.rafIds.get(t);
if (id !== undefined) cancelAnimationFrame(id);
}, this);
this.targets.clear();
}
_rafFallback(target) {
var lastW = -1, lastH = -1, self = this;
function poll() {
if (!document.contains(target)) return;
var r = target.getBoundingClientRect();
if (r.width === 0 && r.height === 0) { self.rafIds.set(target, requestAnimationFrame(poll)); return; }
if (r.width !== lastW || r.height !== lastH) {
lastW = r.width; lastH = r.height;
self.callback([{ target: target, contentRect: r }]);
}
self.rafIds.set(target, requestAnimationFrame(poll));
}
this.rafIds.set(target, requestAnimationFrame(poll));
}
}
Build pipeline note: The wrapper uses standard class syntax that compiles cleanly with @babel/preset-env targeting IE11. If you need private class fields (#field syntax), set targets: { ie: 11, safari: 12 } in your Babel config, or use the WeakMap closure pattern shown in the plain JS block above — private class fields require ES2022+ and will not transpile correctly without explicit configuration.
Verification Steps
After deploying the wrapper, confirm it behaves correctly in both the native and fallback paths:
- Console — feature detection: Open the browser console and run
typeof ResizeObserver. It returns'function'in modern browsers and'undefined'in IE11 / Safari 12, confirming which code path your wrapper takes. - Performance tab — main thread blocking: Record a timeline while resizing the viewport in a legacy environment. Confirm no
Layoutevent appears immediately after agetBoundingClientRect()call (forced synchronous layout); frames should stay under 16 ms. - Memory tab — heap snapshot: Take a snapshot before and after navigating away from a page that uses the wrapper. Zero detached
Elementnodes should remain. If any appear,disconnect()was not called in the unmount hook. - Console — hidden element guard: Set the observed element to
display: noneand confirm no resize callback fires. Therect.width === 0 && rect.height === 0guard should suppress the call. - Network tab — conditional loading: If you load a third-party polyfill file conditionally, verify the network waterfall shows the file loading only in browsers where the native check fails, not in Chrome or Firefox.
Common Mistakes to Avoid
- Skipping
cancelAnimationFrameinunobserve: Callingunobserve()without also cancelling the activerAFloop leaves an orphaned polling callback. It continues callinggetBoundingClientRect()on every frame even after the element is gone, driving up CPU usage and eventually throwingTypeErroron a detached node. - Relying on
WeakMapalone for cleanup:WeakMaplets the GC reclaim entries when keys are no longer reachable, but therAFcallback holds a closure reference to the target, keeping it alive. Explicit cancellation indisconnect()is always required — preventing memory leaks in long-running observers covers this pattern in full. - Calling
new SafeResizeObserver()during SSR:windowanddocumentare undefined in Node.js rendering environments. Defer construction touseEffect(React),onMounted(Vue), orngAfterViewInit(Angular). A top-leveltypeof window === 'undefined'guard inside the constructor prevents the crash. - Not debouncing when measuring on every rAF tick: The native API coalesces rapid size changes and fires once per frame batch. The
rAFfallback fires at every frame if anything changes. For elements that resize very frequently (e.g. during a CSS animation), add a minimum-change threshold — skip the callback unlessMath.abs(rect.width - lastWidth) > 1— to avoid flooding downstream handlers. The syncing observer callbacks with requestAnimationFrame page covers batching strategies that apply here.
FAQ
Why does typeof window.ResizeObserver return 'undefined' even in Chrome 64+ in some setups?
Build tools that statically inline or tree-shake polyfills can evaluate the typeof guard before the polyfill module's side effects run. The result is a false 'undefined' result at the time the guard executes, even though the API will exist once module evaluation completes. Move the guard inside an async function or use a dynamic import() so it runs after all module code has settled.
Is the rAF polling fallback safe for production use?
Yes, provided two conditions are met: you cancel the rAF loop when the target leaves the DOM (the document.contains(target) guard in _rafFallback), and you call disconnect() in the component unmount hook. Without both guards the loop becomes an orphaned callback that continues executing against a detached node, stalling the main thread.
Does the wrapper support the box option of observe()?
The native API accepts { box: 'border-box' | 'content-box' | 'device-pixel-content-box' }. The rAF fallback uses getBoundingClientRect(), which always returns border-box dimensions. The wrapper hardcodes box: 'border-box' on the native path to keep both paths consistent. If your code depends on content-box values you need to measure padding separately in the fallback or load a third-party polyfill that replicates the spec's box model logic.
Related
- Browser Compatibility & Polyfills — support matrices and polyfill strategies for all Observer APIs
- Preventing Memory Leaks in Long-Running Observers — WeakMap patterns and disconnect discipline
- Syncing Observer Callbacks with requestAnimationFrame — batching and scheduling strategies
↑ Back to Browser Compatibility & Polyfills