A callback that never seems to run on mount is rarely a broken observer — it is almost always a timing problem: the element wasn't in the DOM yet, had zero dimensions, or the observer was created before the framework had committed anything to observe.

The Async Initial Notification, Not a Synchronous One

Both IntersectionObserver and ResizeObserver guarantee an initial delivery for every newly observed target — but that guarantee is asynchronous. The spec schedules the first batch of entries after the current task completes, as part of the browser's "update the rendering" steps, not inline inside the observe() call. Code written like this misunderstands that timing:

TypeScript
// WRONG — assumes observe() synchronously updates state
const io = new IntersectionObserver((entries) => {
  isVisible = entries[0].isIntersecting;
});
io.observe(target);
console.log(isVisible); // still the old value — callback hasn't run yet

The callback runs later, in its own turn of the event loop. If your component logic reads a variable the callback is supposed to set, immediately after calling observe(), you are reading it before the browser has had a chance to compute anything. This is functionally identical to expecting a fetch() response before awaiting it — the asynchrony is the point, and skipping it produces a stale read that looks exactly like "the callback never fired," even though it eventually will.

Zero-Dimension and display:none Targets

The second, more common cause is observing an element that has no renderable size at the moment observe() is called. ResizeObserver will not report a meaningful entry for an element with 0×0 content box dimensions, and IntersectionObserver will report isIntersecting: false for a target that is entirely outside the layout — both are technically "firing," but the entry carries no useful signal, which reads to the developer as silence.

This happens constantly with conditionally rendered UI: accordions, tabs, and modals frequently keep their content in the DOM but hidden with display:none until opened.

TypeScript
// A target that is hidden at observe()-time never reports useful data
const panel = document.querySelector<HTMLElement>('#tab-panel-2')!; // display: none right now
const ro = new ResizeObserver((entries) => {
  console.log(entries[0].contentRect); // width/height stay 0 until panel is shown
});
ro.observe(panel);

The fix is to defer observation until the element is actually part of the rendered layout — either by observing it only after toggling visibility, or by observing an always-visible wrapper and reading the panel's dimensions only once it becomes active:

TypeScript
// Observe only once the panel becomes visible
function showPanelAndObserve(panel: HTMLElement, onResize: ResizeObserverCallback): ResizeObserver {
  panel.style.display = 'block'; // must happen before observe(), same task is fine
  const ro = new ResizeObserver(onResize);
  ro.observe(panel);
  return ro;
}
JavaScript
// Plain JS equivalent
function showPanelAndObserve(panel, onResize) {
  panel.style.display = 'block';
  const ro = new ResizeObserver(onResize);
  ro.observe(panel);
  return ro;
}

Setting display: block synchronously before observe() in the same task is safe — the browser computes layout fresh before delivering the observer's first batch, so the initial entry reflects the now-visible dimensions rather than the stale hidden state.

Observing Before the Element Exists

A closely related failure is calling observe() on a reference that has not been attached to anything yet. In component-based frameworks, refs are populated by the framework's own commit phase, not by your function's execution order. Code that runs during rendering — as opposed to after mounting — sees an empty or null reference:

TypeScript
// WRONG — ref.current is null during the render/setup phase
function BrokenComponent(): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);
  const io = new IntersectionObserver(() => {});
  io.observe(ref.current!); // ref.current is null here — throws or silently no-ops
  return <div ref={ref} />;
}

The observer must be created and told to observe() only after the framework has committed the element to the DOM — inside a lifecycle hook, not inline in the render function:

TypeScript
// CORRECT — React: observe inside useEffect, after commit
function FixedComponent(): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return; // guards the same-render race in edge cases
    const io = new IntersectionObserver((entries) => {
      console.log('visible:', entries[0].isIntersecting);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);

  return <div ref={ref} />;
}
TypeScript
// Vue 3 — observe inside onMounted, never in <script setup> top-level code
import { ref, onMounted, onUnmounted } from 'vue';

const target = ref<HTMLElement | null>(null);

onMounted(() => {
  if (!target.value) return;
  const io = new IntersectionObserver((entries) => {
    console.log('visible:', entries[0].isIntersecting);
  });
  io.observe(target.value);
  onUnmounted(() => io.disconnect());
});
TypeScript
// Angular — observe inside ngAfterViewInit, never in the constructor
@Component({ selector: 'app-tracked', template: `<div #tracked></div>` })
export class TrackedComponent implements AfterViewInit, OnDestroy {
  @ViewChild('tracked', { static: true }) tracked!: ElementRef<HTMLElement>;
  private observer!: IntersectionObserver;

  ngAfterViewInit(): void {
    this.observer = new IntersectionObserver((entries) => {
      console.log('visible:', entries[0].isIntersecting);
    });
    this.observer.observe(this.tracked.nativeElement);
  }

  ngOnDestroy(): void {
    this.observer.disconnect();
  }
}

The constructor phase in Angular, the render body in React, and the top level of <script setup> in Vue all run before the DOM node is guaranteed to exist. ngAfterViewInit, useEffect, and onMounted are the earliest points where the framework promises the element is committed and stable enough to observe.

The Timing Sequence, Visually

The diagram below lines up the framework's render/commit sequence against the two most common places an observer gets created — one that is too early and produces nothing, one that is correctly timed and produces the expected initial entry.

Observer creation timing: render phase vs. mount effect A horizontal timeline showing Render, DOM Commit, and Mount Effect stages. An observer created during Render has no element to attach to and never fires. An observer created during the Mount Effect, after DOM Commit, observes successfully and receives its initial asynchronous callback. Render DOM Commit Mount Effect Next microtask observe(ref.current) ref.current is null no callback ever observe(el) element is attached ✓ initial entry delivered

React StrictMode: The Double-Invocation Trap

In development, React's StrictMode deliberately mounts every component, runs its cleanup functions, and mounts it again — specifically to catch effects that assume they only run once. If an effect creates an observer but the returned cleanup function doesn't call disconnect(), the first observer is abandoned (not disconnected) and a second one is created on the simulated remount. Both remain active, so you get two initial callbacks and, worse, two callbacks for every subsequent change:

TypeScript
// WRONG — no cleanup means StrictMode leaves a dangling first observer
useEffect(() => {
  const io = new IntersectionObserver(callback);
  io.observe(ref.current!);
  // missing: return () => io.disconnect();
}, []);
TypeScript
// CORRECT — cleanup makes StrictMode's double-invocation harmless
useEffect(() => {
  if (!ref.current) return;
  const io = new IntersectionObserver(callback);
  io.observe(ref.current);
  return () => io.disconnect(); // runs before the simulated remount, and on real unmount
}, []);

This symptom is easy to mistake for "the callback isn't called on initial render" because in development the visible effect is duplicate or seemingly erratic firing, while in a production build without StrictMode the same broken effect produces only a single leaked observer per mount — which can itself later masquerade as a callback that silently stops updating after a route change, since the stale observer keeps watching a node no one references anymore. For a systematic look at hooks that handle this correctly the first time, see the React observer hooks guide.

ResizeObserver's Initial Fire Semantics

ResizeObserver has its own version of this guarantee: the first time you call observe() on a target, the spec requires the browser to queue one observation for it on the very next available cycle, regardless of whether the element's size has changed relative to anything — there is no "previous size" to compare against yet. If that first entry never seems to arrive, the near-universal cause is that the target had 0×0 content box dimensions at observation time, most often because a parent flex or grid container hadn't finished laying out, or the target was inside a closed <details> element or an overflow: hidden ancestor collapsed to zero height.

TypeScript
// Confirms whether the "missing" initial fire is actually a zero-size target
const ro = new ResizeObserver((entries) => {
  const { width, height } = entries[0].contentRect;
  if (width === 0 && height === 0) {
    console.warn('Initial entry arrived, but target has zero size — check display/layout, not the observer.');
  }
});
ro.observe(target);

Debugging Checklist

Work through these in order before assuming the observer itself is defective:

  • Confirm you're reading state after the callback, not immediately after observe(). The initial notification is always asynchronous; synchronous reads right after observe() will see stale values.
  • Log inside the callback itself. If nothing prints at all, observe() was likely never actually called on a valid element — check for null refs or a guard clause that returned early.
  • Check the target's computed dimensions at the moment observe() runs. getComputedStyle(target).display and target.getBoundingClientRect() reveal zero-size or hidden targets immediately.
  • Verify observation happens in a mount lifecycle hook, not in a constructor, module scope, or component render body.
  • In React, confirm the effect's cleanup calls disconnect(). Missing cleanup under StrictMode produces confusing duplicate-or-silent behavior that looks like a mount timing bug.
  • Check for SSR guards that never resolve on the client. A typeof window === 'undefined' guard that isn't re-evaluated after hydration can permanently skip observer creation. See the observer lifecycle and memory management guide for the full teardown and instantiation discipline.

FAQ

Why doesn't my observer callback fire the instant I call observe()?

Both IntersectionObserver and ResizeObserver deliver their first notification asynchronously — after the current task finishes, in a microtask-scheduled batch — never synchronously inside the observe() call itself. Code that reads state immediately after calling observe() and expects it to already reflect the callback's result will always see stale data, because the callback has not run yet.

Can I observe a ref before the DOM element it points to actually exists?

No. Calling observe() with null or with an element that has not yet been attached to the document silently does nothing in most implementations, or throws in others. In React, a ref's .current is null during the render phase and is only populated after the DOM has committed, which is why observers must be created in an effect (useEffect, onMounted, ngAfterViewInit), never in the render body.

Why does my callback fire twice on mount in React?

React's StrictMode intentionally mounts, unmounts, and remounts every component once in development to surface effects that aren't cleanup-safe. If your effect creates an observer without returning a disconnect() cleanup function, the first observer leaks and a second one is created on remount, producing duplicate callbacks. Returning disconnect() from the effect makes the double-invocation harmless.

Does ResizeObserver fire an initial callback even if the element's size hasn't changed?

Yes. The spec requires ResizeObserver to deliver one initial entry for every newly observed target on the next available observation cycle, reporting its current size even though no resize has actually occurred yet. If that first callback never arrives, the target almost always has zero width and height at the moment observe() was called, commonly because it was display:none or not yet laid out.

↑ Back to Observer Lifecycle & Memory Management