Skip to main content

The DOM observation problem: ResizeObserver, MutationObserver, and tours

Map ResizeObserver, MutationObserver, and IntersectionObserver to product tour problems they solve, with cleanup patterns and benchmarks.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202612 min read
Share
The DOM observation problem: ResizeObserver, MutationObserver, and tours

The DOM observation problem in product tours

Product tour tooltips break when the DOM changes underneath them. A target element resizes, a sidebar collapses, a lazy-loaded image pushes content down. Your carefully positioned tooltip now points at nothing. The browser gives you three observer APIs to handle this, but none of them cover the full picture on their own. Understanding which observer solves which problem is the difference between a tour that works on a demo page and one that survives real user sessions.

npm install @tourkit/core @tourkit/react

This article maps each DOM observer API to the specific product tour positioning problem it addresses, walks through the cleanup patterns that prevent memory leaks, and shows how Tour Kit handles observation internally so you don't have to wire it up yourself.

What is the DOM observation problem in product tours?

Product tour tooltips must track the position of a target element and reposition themselves whenever the layout shifts. The fundamental challenge is that no single browser API tells you "this element's position on screen changed." As of April 2026, the W3C has no PositionObserver or ClientRectObserver spec (a WICG proposal exists but hasn't progressed). Tour libraries instead combine multiple observer APIs with event listeners to approximate position tracking.

Floating UI, the positioning engine used by most modern tooltip libraries, runs four observation strategies simultaneously: ResizeObserver for element size changes, IntersectionObserver for layout shift detection, scroll event listeners for ancestor scrolling, and resize event listeners for viewport changes. Position updates take roughly 1ms (Floating UI docs, 2026). That speed comes from knowing exactly which API to fire for each type of DOM change.

Why DOM observation matters for product tours

Broken tooltip positioning is the top reason users abandon product tours early. When a tooltip points at the wrong element or floats in empty space, the tour loses credibility. A study of 500 repositories found 787 instances of missing observer disconnects, leaking roughly 8 KB per observation cycle (StackInsight, 2024). Most tour implementations either skip observation entirely or implement it with memory leaks.

The business impact is concrete. Users who complete onboarding tours convert at 2.5x the rate of those who don't (Pendo's 2024 Product Benchmarks). But tour completion drops sharply when steps misalign with their targets. ResizeObserver browser support reached 96.04% globally as of April 2026 (Can I Use), MutationObserver sits at 98.4%, and IntersectionObserver at 97.2%. The APIs are ready. The implementations lag behind.

The three observers and what they actually detect

Each observer API answers a different question about DOM state. Mixing them up, or using the wrong one, leads to either missed repositioning or wasted cycles.

ObserverDetectsTour use caseFiresManual disconnect needed?
ResizeObserverElement size changesTooltip repositioning when target or tooltip resizesBefore paint, after layoutYes (leaks memory without it)
MutationObserverDOM tree changes (childList, attributes, characterData)Detecting when a tour target is added, removed, or moved in the DOMAsynchronous, batchedNo (garbage collected automatically)
IntersectionObserverElement visibility relative to viewport or containerVerifying tour target is visible before showing a stepAsynchronous, threshold-basedYes (leaks memory without it)

That "manual disconnect" column is the detail that causes real bugs in production. Jake Archibald from the Chrome team confirmed: "ResizeObserver & IntersectionObserver need to be manually disconnected, else they leak memory through their callback. This happens in all browsers. MutationObserver and event listeners don't have this issue" (Jake Archibald, 2021). A study of 500 repositories found 787 instances of missing observer disconnects, leaking roughly 8 KB per observation cycle (StackInsight, 2024).

ResizeObserver: keeping tooltips positioned during layout shifts

ResizeObserver fires before paint and after layout, which is the ideal timing for tooltip repositioning. Supported in Chrome 64+, Firefox 69+, Safari 13.1+, and Edge 79+ (MDN, 2026), it covers 96% of global browser traffic. When a tour target changes size (an accordion opens, a text field validates), ResizeObserver tells you the new dimensions before the browser paints the frame. Your tooltip moves in the same visual update.

// src/hooks/useTargetResize.ts
import { useEffect, useRef } from 'react';

function useTargetResize(
  targetRef: React.RefObject<HTMLElement | null>,
  onResize: (entry: ResizeObserverEntry) => void
) {
  const observerRef = useRef<ResizeObserver | null>(null);

  useEffect(() => {
    const target = targetRef.current;
    if (!target) return;

    observerRef.current = new ResizeObserver((entries) => {
      // Only process the first entry since we observe one element
      if (entries[0]) onResize(entries[0]);
    });

    observerRef.current.observe(target);

    return () => {
      // Critical: disconnect prevents memory leak
      observerRef.current?.disconnect();
      observerRef.current = null;
    };
  }, [targetRef, onResize]);
}

The gotcha we hit when building Tour Kit's position engine: ResizeObserver has infinite loop protection. If your resize callback triggers another resize on a shallower DOM element, the browser defers the update to the next frame. This creates the infamous ResizeObserver loop limit exceeded error that shows up in MUI, Ant Design, Swiper, and floating-ui (TrackJS).

The error is benign. It means the browser skipped a frame to prevent an infinite layout cycle. Filter it from your error monitoring.

The fix if you're triggering it intentionally: wrap your callback in requestAnimationFrame:

// src/hooks/useTargetResizeSafe.ts
observerRef.current = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    if (entries[0]) onResize(entries[0]);
  });
});

MutationObserver: detecting when tour targets appear or disappear

MutationObserver watches for DOM tree changes: elements added, removed, or having their attributes changed. It's the W3C-approved replacement for deprecated Mutation Events (Smashing Magazine, 2019), supported in Chrome 26+, Firefox 14+, and Safari 7+. At 98.4% global coverage, it's the oldest and most widely supported observer.

For product tours, MutationObserver solves the "target doesn't exist yet" problem. Your tour step points at a button that only renders after an API call returns. MutationObserver tells you when it arrives.

// src/hooks/useTargetMutation.ts
import { useEffect, useRef } from 'react';

function useTargetMutation(
  selector: string,
  onChange: (target: Element | null) => void
) {
  const observerRef = useRef<MutationObserver | null>(null);

  useEffect(() => {
    // Check if target already exists
    const existing = document.querySelector(selector);
    if (existing) onChange(existing);

    observerRef.current = new MutationObserver(() => {
      const target = document.querySelector(selector);
      onChange(target);
    });

    // Observe only the subtree containing likely targets
    const root = document.getElementById('app') ?? document.body;
    observerRef.current.observe(root, {
      childList: true,
      subtree: true,
    });

    return () => {
      observerRef.current?.disconnect();
    };
  }, [selector, onChange]);
}

The main downside of MutationObserver is the firehose problem. Observing document.body with subtree: true fires your callback on every DOM change across the entire page. As samthor.au puts it: "The main downside for MutationObserver is that you get a firehose of all events."

For product tours, the mitigation is scope narrowing. Don't observe document.body if you know the tour target will appear inside a specific container. Observe the closest stable ancestor instead.

Keep callbacks cheap. A querySelector check is fast enough even at high mutation frequencies. Unlike ResizeObserver and IntersectionObserver, MutationObserver is garbage collected automatically when its reference goes out of scope. You should still call disconnect() for explicit cleanup, but a forgotten MutationObserver won't leak memory.

IntersectionObserver: verifying target visibility before showing a step

IntersectionObserver tells you whether an element is visible in the viewport (or a scrollable container), supported in Chrome 51+, Firefox 55+, and Safari 12.1+ at 97.2% global coverage (MDN, 2026). For tours, this answers: "Should I show this step right now, or is the target scrolled out of view?"

// src/hooks/useTargetVisibility.ts
import { useEffect, useRef, useState } from 'react';

function useTargetVisibility(
  targetRef: React.RefObject<HTMLElement | null>,
  threshold = 0.5
) {
  const [isVisible, setIsVisible] = useState(false);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    const target = targetRef.current;
    if (!target) return;

    observerRef.current = new IntersectionObserver(
      ([entry]) => {
        if (entry) setIsVisible(entry.isIntersecting);
      },
      { threshold }
    );

    observerRef.current.observe(target);

    return () => {
      // Critical: disconnect prevents memory leak
      observerRef.current?.disconnect();
      observerRef.current = null;
    };
  }, [targetRef, threshold]);

  return isVisible;
}

IntersectionObserver v2 adds trackVisibility: true, which detects whether an element is actually visible. Not just geometrically in the viewport, but not hidden behind other elements, not at opacity: 0, not transformed off screen (web.dev, IntersectionObserver v2). This matters for tours where a modal might cover the target, or where CSS transitions temporarily hide it.

The practical limitation: trackVisibility requires a minimum delay of 100ms between callbacks, so you trade responsiveness for accuracy. For most tour implementations, the standard isIntersecting check is sufficient — pair it with a scroll-into-view call if the target isn't visible.

Check out Tour Kit's scroll handling

The cleanup contract: what happens when a tour step unmounts

Memory leaks from observers compound. A tour with 10 steps that doesn't clean up properly leaks roughly 80 KB per session (10 steps x 8 KB per uncleaned observer). Over a 30-minute session with 5 tour activations, that's 400 KB of leaked DOM references. The Chromium team tracks this as bug #40772627. The cleanup contract for a product tour looks like this:

When a step deactivates (user clicks "Next"):

  1. ResizeObserver.disconnect() on the current target
  2. IntersectionObserver.disconnect() on the current target
  3. MutationObserver can be kept alive if the next target doesn't exist yet

When the tour closes (user finishes or dismisses):

  1. Disconnect all active observers
  2. Remove all scroll/resize event listeners
  3. Null out observer references to help garbage collection

When a target element is removed from the DOM:

  1. ResizeObserver entries hold strong references. You must unobserve() or disconnect() before the element is removed, or the observer retains a reference to the detached DOM node
  2. Safari has an additional issue: WebKit bug #227194 causes ResizeObserver to leak even after disconnect if the observed element was detached from the DOM before cleanup
// src/hooks/useTourObservers.ts
import { useEffect, useRef, useCallback } from 'react';

interface ObserverSet {
  resize: ResizeObserver | null;
  intersection: IntersectionObserver | null;
  mutation: MutationObserver | null;
}

function useTourObservers() {
  const observers = useRef<ObserverSet>({
    resize: null,
    intersection: null,
    mutation: null,
  });

  const cleanup = useCallback(() => {
    observers.current.resize?.disconnect();
    observers.current.intersection?.disconnect();
    observers.current.mutation?.disconnect();
    observers.current = {
      resize: null,
      intersection: null,
      mutation: null,
    };
  }, []);

  // Disconnect everything on unmount
  useEffect(() => cleanup, [cleanup]);

  return { observers, cleanup };
}

Tour Kit handles this cleanup automatically through its step lifecycle. When you use useTour(), observer setup and teardown happen inside the hook. You don't call disconnect() yourself.

CSS-first vs observer-first: two valid architectures

Not every tour library needs observers. Sentry Engineering built their product tour using a CSS-first approach with zero observer APIs: z-index layering, pseudo-elements for spotlight overlays, and react-popper (built on Popper.js at 27.8K npm weekly downloads as of April 2026) for tooltip positioning. Their key design decision: "We are not re-parenting children ever! This is critical to avoiding layout shift" (Sentry Engineering, 2022).

The CSS-first approach works when:

  • Tour targets are static (don't resize, don't move, don't appear/disappear dynamically)
  • The tour runs on pages you control (no third-party widgets, no user-generated content)
  • You can guarantee the target's position through CSS alone

The observer-first approach (what floating-ui and Tour Kit use) is necessary when:

  • Targets resize (responsive layouts, accordion panels, dynamic content)
  • Targets may not exist when the tour starts (lazy-loaded components, async data)
  • The page layout shifts during the tour (sidebar toggles, notifications, chat widgets)
  • You need to verify visibility before showing a step

Most real-world SaaS products need the observer-first approach. Dashboards resize panels. Settings pages load sections asynchronously. Feature flags show or hide UI regions. React Joyride (5.1K GitHub stars, the most popular React tour library as of April 2026) uses floating-ui's observer-based positioning for this reason. The CSS-first approach is elegant when it works, but it breaks the moment your DOM isn't static.

Common mistakes and how to avoid them

Observing document.body with MutationObserver when you only need one subtree. Narrow your observation scope. If your tour target will appear inside #main-content, observe that container, not the entire document. Every mutation across the whole page triggers your callback otherwise.

Forgetting to disconnect ResizeObserver when the component unmounts. Use the cleanup function in useEffect. If you're using a class component (which you probably shouldn't be in 2026), disconnect in componentWillUnmount. Tour Kit handles this in its step lifecycle, so if you're using the library you don't need to worry about it.

Filtering out ResizeObserver loop limit exceeded errors incorrectly. Don't try/catch the observer creation — the error fires asynchronously on window.onerror. Add a global error handler that filters this specific message, or configure your error monitoring tool (Sentry, TrackJS, Datadog) to ignore it.

Using animationFrame polling instead of observers. Floating UI offers an animationFrame option in autoUpdate() that polls position 60 times per second. This works but burns CPU. Use it only as a last resort when observer-based strategies miss edge cases. The docs warn: "should only be called/set-up when the floating element is open on the screen" (Floating UI, 2026).

Not handling the case where a target is removed mid-observation. If the DOM element your ResizeObserver watches gets removed, the observer holds a strong reference to the detached node. Pair your MutationObserver (watching for node removal) with cleanup logic that disconnects the ResizeObserver when the target disappears.

How Tour Kit handles DOM observation

Tour Kit's @tourkit/core position engine abstracts observer management entirely. When you define a tour step with a target selector, the library:

  1. Uses MutationObserver to wait for the target element to appear in the DOM
  2. Attaches ResizeObserver to both the target and the tooltip for bidirectional size tracking
  3. Uses IntersectionObserver to check target visibility before showing the step
  4. Scrolls the target into view if it's outside the viewport
  5. Disconnects all observers when the step transitions or the tour closes
// src/App.tsx
import { TourProvider, useTour } from '@tourkit/react';

const steps = [
  {
    target: '#dashboard-chart', // May not exist on first render
    content: 'This chart updates in real time.',
  },
  {
    target: '.settings-panel',  // Inside a collapsible sidebar
    content: 'Configure your preferences here.',
  },
];

function App() {
  return (
    <TourProvider steps={steps}>
      <Dashboard />
      <TourControls />
    </TourProvider>
  );
}

No manual observer wiring. No cleanup boilerplate. The library handles the observation problem so you can focus on the tour content. Tour Kit ships at under 8 KB gzipped for the core package, and the observer logic adds minimal overhead because it only activates observers for the current step, not all steps simultaneously.

Tour Kit currently requires React 18 or later, which is worth noting. The observer cleanup relies on React's useEffect cleanup semantics, which behave differently in React 17's legacy rendering mode. If you're on React 17, you'll need to handle observer lifecycle manually.

Get started with Tour Kit

FAQ

Which observer API should I use for tooltip repositioning in a product tour?

ResizeObserver is the primary choice because it fires before paint, giving you updated dimensions in time to reposition the tooltip within the same frame. Combine it with scroll event listeners for ancestor scrolling and IntersectionObserver for visibility checking. Floating UI's autoUpdate demonstrates this combined pattern with roughly 1ms update times.

Does MutationObserver cause memory leaks like ResizeObserver?

No. MutationObserver is garbage collected automatically when its reference goes out of scope. ResizeObserver and IntersectionObserver require explicit disconnect() calls or they leak roughly 8 KB per cycle. Jake Archibald from the Chrome team confirmed this asymmetry across all browsers.

What causes the "ResizeObserver loop limit exceeded" error?

The error fires when a ResizeObserver callback triggers a resize on an element shallower in the DOM tree, creating a potential infinite loop. The browser defers the update to the next frame and fires this error as a safeguard. It's not a bug. MUI, Ant Design, and floating-ui all trigger it. Filter it from your error monitoring.

How do I handle tour targets that don't exist yet in the DOM?

Use MutationObserver on the closest stable ancestor element with childList: true and subtree: true. When the observer fires, run document.querySelector with your target selector. This handles lazy-loaded components, async data, and conditionally rendered UI. Tour Kit does this automatically when you specify a CSS selector.

Can IntersectionObserver detect if a tour target is hidden behind a modal?

Standard IntersectionObserver only checks geometric intersection with the viewport, not visual occlusion. IntersectionObserver v2 with trackVisibility: true can detect occlusion, opacity changes, and CSS transforms, but requires a minimum 100ms callback delay. For most tours, checking isIntersecting combined with your own modal state management is more practical.


Internal linking suggestions:

Distribution checklist:

  • Dev.to (full republish with canonical URL)
  • Hashnode (full republish with canonical URL)
  • Reddit r/reactjs (discussion post linking to article)
  • Reddit r/webdev (discussion post linking to article)
  • Hacker News (submit with DOM observation angle, not product tour angle)

Ready to try userTourKit?

$ pnpm add @tour-kit/react