Mocking IntersectionObserver and ResizeObserver in jsdom
IntersectionObserver and ResizeObserver power a huge share of modern UI — lazy-loaded images, infinite scroll, sticky headers, responsive charts, and virtualized lists — yet neither exists in jsdom or happy-dom. The moment a component instantiates one under test, you get ReferenceError: IntersectionObserver is not defined, and even a bare polyfill leaves you with no way to trigger the callback, because there is no real viewport or layout engine to drive it. This guide is for frontend developers and QA engineers running Vitest 2.x (the patterns port directly to Jest 29.x) who need to test observer-driven components deterministically. It covers stubbing both constructors and, crucially, firing their callbacks on demand. It is the in-process complement to the runtime-isolation work in DOM & Browser API Mocking.
Root Cause Analysis
Both observer APIs belong to the browser platform layer, not the DOM core, so jsdom — which faithfully implements the DOM and HTML specifications and nothing more — deliberately omits them. The reasoning is principled rather than an oversight: IntersectionObserver needs a real viewport and a scroll position to compute intersection ratios against, and ResizeObserver needs a layout engine that actually flows boxes and reports their measured dimensions. jsdom has neither. It parses markup into a node tree and runs script, but it never performs layout, so getBoundingClientRect returns a rectangle of zeros for every element. Shipping a genuine implementation of either observer would therefore give you an API that compiles but can never fire, because it would be watching geometry that is frozen at the origin forever.
That absence surfaces in two distinct stages, and understanding the difference is what steers you toward the right fix rather than a fix that only appears to work. The first stage is a missing-global crash: any component calling new IntersectionObserver(cb) throws ReferenceError the instant it mounts, which unwinds the entire render and fails the test with a stack trace that points at the constructor. This one is loud and easy to diagnose. The second stage is far more insidious — the silent no-op trap. A developer, reasonably, reaches for the smallest possible stub: a class whose observe method does nothing. The ReferenceError disappears, the component mounts, the test goes green on the initial assertion, and everyone moves on. But the callback that was supposed to swap the image in, load the next page, or re-measure the chart never runs, because nothing in the stub ever calls it. The component “renders” in the narrowest sense while the behaviour you actually care about is never exercised at all. Tests written on top of that stub assert the loading state and quietly stop testing the feature.
The fix therefore has to do two jobs at once: satisfy the constructor so mounting succeeds, and expose a handle to the registered callback so the test can drive it. There is also a subtle behavioural detail worth preserving. Real observers are asynchronous — the browser batches intersection and resize notifications and delivers them on a dedicated task, never synchronously inside observe(). A test double does not have to replicate that scheduling, and for determinism it is better that it does not; firing synchronously and wrapping the call in React’s act gives you a tighter, more predictable flush than trying to emulate the microtask queue. What matters is that you decide when the callback runs. Storing every constructed instance and its callback in a registry is what turns a dead stub into a controllable one — the same controllability principle that underpins all Advanced Mocking & Service Isolation Patterns. Control over timing is the entire value proposition: a polyfill would recompute geometry you cannot influence, whereas a registry-backed mock hands you the trigger.
Reproducible Setup
Confirm a DOM environment is active. jsdom is the spec-complete choice; the stubs work identically under happy-dom, and switching between the two changes nothing about the mock because neither engine supplies the observers in the first place.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
});
A representative component lazy-loads an image once it scrolls into view. It is deliberately small, but it exhibits the exact shape that breaks under test: it constructs an observer in an effect, observes a ref, and flips state from inside the callback.
// src/components/LazyImage.tsx
import { useEffect, useRef, useState } from 'react';
export function LazyImage({ src }: { src: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
});
if (ref.current) io.observe(ref.current);
return () => io.disconnect();
}, []);
return (
<div ref={ref} data-testid="lazy-root">
{visible ? <img src={src} alt="" data-testid="loaded" /> : <span>Loading…</span>}
</div>
);
}
Implementation
The strategy is a controllable mock class that records every instance and the callback it was constructed with, plus a helper that fires synthetic entries on demand. Read the four steps as one unit: the class provides the surface the constructor needs, the registry provides the addressability, and the trigger helper provides the timing control.
Step 1: Build a controllable IntersectionObserver mock.
The class implements the real interface so TypeScript stays happy, but the only members that do real work are the constructor — which pushes this onto a static instances array — and observe, which records which elements this particular instance is watching. Everything else is a faithful but inert stub.
// src/test-utils/observer-mocks.ts
type IOCallback = IntersectionObserverCallback;
class MockIntersectionObserver implements IntersectionObserver {
static instances: MockIntersectionObserver[] = [];
readonly root = null;
readonly rootMargin = '';
readonly thresholds = [];
callback: IOCallback;
observed = new Set<Element>();
constructor(cb: IOCallback) {
this.callback = cb;
MockIntersectionObserver.instances.push(this);
}
observe(el: Element) { this.observed.add(el); }
unobserve(el: Element) { this.observed.delete(el); }
disconnect() { this.observed.clear(); }
takeRecords(): IntersectionObserverEntry[] { return []; }
}
export function installIntersectionObserver() {
MockIntersectionObserver.instances = [];
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return MockIntersectionObserver;
}
The instances array is reset inside installIntersectionObserver rather than being cleared implicitly, so each installation starts from a known-empty registry. That reset is what prevents observers constructed in one test from lingering into the next — a stale instance that still holds a reference to a torn-down element is a subtle source of cross-test contamination.
Step 2: Add a deterministic trigger helper.
The helper synthesizes an IntersectionObserverEntry for a specific element and invokes the stored callback synchronously. Because it iterates the registry and matches on the observed set, it only fires the callbacks of instances that are actually watching the element you name — a targeted dispatch rather than a broadcast.
// src/test-utils/observer-mocks.ts (continued)
export function triggerIntersection(el: Element, isIntersecting: boolean) {
for (const inst of MockIntersectionObserver.instances) {
if (inst.observed.has(el)) {
const entry = {
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
target: el,
boundingClientRect: el.getBoundingClientRect(),
intersectionRect: el.getBoundingClientRect(),
rootBounds: null,
time: Date.now(),
} as IntersectionObserverEntry;
inst.callback([entry], inst as unknown as IntersectionObserver);
}
}
}
Note the shape of the synthetic entry. intersectionRatio is coupled to isIntersecting so callbacks that gate on a threshold (ratio > 0.5, say) behave sensibly, and rootBounds is null because there is no root element by default — the same value the browser reports when observing against the implicit viewport. If a component branches on any other field, add it here; the entry is a plain object you own, so extending it is trivial.
Step 3: Mirror the pattern for ResizeObserver.
ResizeObserver follows the same shape; its entries carry contentRect instead of intersection geometry, and its callback receives box-size arrays that most components ignore but which the type demands.
// src/test-utils/observer-mocks.ts (continued)
class MockResizeObserver implements ResizeObserver {
static instances: MockResizeObserver[] = [];
callback: ResizeObserverCallback;
observed = new Set<Element>();
constructor(cb: ResizeObserverCallback) {
this.callback = cb;
MockResizeObserver.instances.push(this);
}
observe(el: Element) { this.observed.add(el); }
unobserve(el: Element) { this.observed.delete(el); }
disconnect() { this.observed.clear(); }
}
export function installResizeObserver() {
MockResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', MockResizeObserver);
return MockResizeObserver;
}
export function triggerResize(el: Element, rect: Partial<DOMRectReadOnly>) {
for (const inst of MockResizeObserver.instances) {
if (inst.observed.has(el)) {
const entry = {
target: el,
contentRect: { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, ...rect },
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry;
inst.callback([entry], inst as unknown as ResizeObserver);
}
}
}
The ...rect spread lets a caller pass only the dimensions that matter — triggerResize(el, { width: 320 }) — while the defaulted zeros fill in the rest, so a responsive chart that only reads contentRect.width needs a one-line trigger rather than a fully-populated rectangle.
Step 4: Register the stubs in the setup file so every test inherits them.
// src/test-setup.ts
import { beforeEach, afterEach, vi } from 'vitest';
import { installIntersectionObserver, installResizeObserver } from './test-utils/observer-mocks';
beforeEach(() => {
installIntersectionObserver();
installResizeObserver();
});
afterEach(() => {
vi.unstubAllGlobals();
});
Registering in beforeEach rather than once at module scope guarantees a fresh registry per test, and vi.unstubAllGlobals() in afterEach restores the original (absent) globals. That symmetry matters: a test that forgets to reset the registry can inherit an observer from a previous case, and because the globals are process-wide, the leak crosses test boundaries in a way that is maddening to trace. The teardown also prevents the state bleed across workers that the parent guide on DOM & Browser API Mocking warns about.
Verification
A test now mounts the component, fires the callback, and asserts the rendered result — all synchronously and deterministically. The first assertion confirms the pre-intersection state so a false green (the component already showing the image for the wrong reason) cannot slip through; the trigger then advances the state; the final assertion confirms the outcome.
// src/__tests__/lazy-image.test.ts
import { render, screen } from '@testing-library/react';
import { act } from 'react';
import { expect, it } from 'vitest';
import { triggerIntersection } from '../test-utils/observer-mocks';
import { LazyImage } from '../components/LazyImage';
it('swaps to the full image after intersection', () => {
render(<LazyImage src="/hero.jpg" />);
expect(screen.queryByTestId('loaded')).toBeNull();
act(() => {
triggerIntersection(screen.getByTestId('lazy-root'), true);
});
expect(screen.getByTestId('loaded')).toBeInTheDocument();
});
A passing run is unambiguous and — because nothing depends on timers, layout, or scroll position — stable on repeat:
✓ src/__tests__/lazy-image.test.ts (1)
✓ swaps to the full image after intersection
Test Files 1 passed (1)
Tests 1 passed (1)
The determinism is the point. There is no waitFor, no fake timers, and no polling for a viewport event, because the test controls the exact moment the callback runs. Wrapping the trigger in act and asserting through Testing Library queries keeps the test aligned with Testing Library best practices, which favour asserting on what the user sees over inspecting mock internals. To prove cleanup, you can also assert against the registry after unmounting — the recorded instance’s observed set should be empty once disconnect has run — which verifies teardown without reaching into component state.
Troubleshooting
ReferenceError: IntersectionObserver is not defined persists. The stub registered after the component read the global. This usually means the constructor runs at module-evaluation time — for example an observer created at the top level of a module rather than inside an effect — or that the setup file is not actually loaded. Diagnosis: check that setupFiles in vitest.config.ts points at the right path and that the install call sits in beforeEach, before any render. If the global is captured at import, move the construction into a lifecycle hook so it runs after the stub is in place.
The callback fires but the UI never updates. The React state change happened outside act, so React batched it but never flushed it before your assertion ran. Diagnosis: an act(...) warning in the console is the tell. Fix: wrap every triggerIntersection and triggerResize call in act, as shown above, so the synchronous callback and the re-render it schedules are flushed together before the next line executes.
contentRect reads all zeros. jsdom performs no layout, so getBoundingClientRect and any real measurement return zeros regardless of CSS. Diagnosis: assertions comparing against genuine pixel dimensions fail even though the component logic is correct. Fix: pass explicit dimensions to triggerResize so the entry reports the size your test expects; if the behaviour under test genuinely depends on real layout — reflow thresholds, wrapping, container queries resolved by the engine — move that check to a real browser via Playwright component testing, where an actual layout pass exists.
The trigger fires but nothing happens because the element never matched. The observed set is keyed by element identity, so if the test passes a different node than the one the component observed — a wrapper instead of the ref target, or a re-queried element after a re-render swapped the DOM node — the registry finds no match and the callback silently does not run. Diagnosis: add a temporary assertion that MockIntersectionObserver.instances.some(i => i.observed.has(el)) is true before triggering. Fix: trigger with the exact element the component observed, typically the one carrying the data-testid on the ref.
FAQ
Does this work with Jest as well as Vitest?
Yes. Replace vi.stubGlobal('IntersectionObserver', Mock) with global.IntersectionObserver = Mock (or use jest.spyOn on the global object) and swap vi.unstubAllGlobals() for an explicit delete (global as any).IntersectionObserver in afterEach. The mock class, the instance registry, and the trigger helpers are entirely framework-agnostic, so only the two registration and restoration lines differ between runners. Everything in Steps 1 through 3 is plain TypeScript that neither runner knows or cares about.
Why not just install the npm polyfill instead of writing a mock?
A polyfill reproduces the real intersection algorithm, but in jsdom there is no viewport, no scrolling, and no layout to feed it, so it can never decide on its own that an element has become visible. The whole purpose of the test is to control when the callback fires and with what geometry; a hand-written mock with a trigger helper gives you exactly that determinism, whereas a polyfill leaves you waiting on state changes that never arrive because the inputs it reads are permanently frozen at zero.
How do I test that the component disconnects the observer on unmount?
Assert against the registry. After unmounting the component, check that the relevant instance’s observed set is empty, or install a spy on disconnect before mounting and assert it was called. Because every constructed instance is recorded in MockIntersectionObserver.instances, you can confirm cleanup happened purely from the outside without reaching into component internals — and a leaked observer that keeps a detached node alive is one of the memory failure modes flagged in DOM & Browser API Mocking.
Can one mock serve multiple observed elements in the same test?
Yes, and this is where the registry design pays off. Every instance tracks its own observed set, so triggerIntersection(elementA, true) fires only the callbacks watching elementA and leaves the callbacks for elementB and elementC untouched, exactly as the fan-out diagram above shows. That isolation lets you drive a virtualized list one row at a time and assert incremental rendering — row three appears, rows four and five do not yet — without any cross-talk between elements corrupting the result.
Should the trigger fire synchronously or asynchronously like the real API?
Fire synchronously for tests. Real browsers batch and deliver observer notifications on a separate task, but reproducing that scheduling in a test only adds a timing seam you then have to await and stabilize. A synchronous trigger wrapped in act flushes the callback and its resulting re-render in a single, ordered step, which is both simpler to reason about and immune to the ordering flakiness that async delivery would reintroduce. If you specifically need to test debounced or batched handling, model that in the component’s own timing logic with fake timers rather than in the observer mock.
Related
- Back to DOM & Browser API Mocking
- Simulating WebSocket connections in Playwright component tests — the real-time transport sibling to observer mocking.
- Playwright component testing — when zeroed jsdom geometry forces a real browser.
- Testing Library best practices — assert on rendered output, not mock plumbing.