Every observer in the browser answers the same question — what changed? — but each one answers it at a different moment in the frame. Code that works in a demo and flickers in production almost always has a timing assumption baked into it, and the fix starts with knowing exactly where each callback lands.

Concept Framing

The four observer families look alike from the outside: a constructor that takes a callback, an observe() method, and a disconnect(). That surface similarity hides the most important difference between them, which is when the callback runs. The Core Observer Fundamentals section covers what each API measures; this topic covers when it reports.

There are three distinct scheduling models in play:

  • Microtask delivery. MutationObserver queues a microtask as soon as the first mutation record is created. The callback runs when the current script — and every other queued microtask ahead of it — finishes, long before the browser gets anywhere near style or layout.
  • Rendering-step delivery. ResizeObserver and IntersectionObserver are computed during the update the rendering steps of the HTML event loop, after requestAnimationFrame callbacks and after style and layout have been brought up to date. ResizeObserver callbacks run inside those steps, before paint. IntersectionObserver computes its entries there too, but delivers them by queueing a task, so its callback runs after the frame has been painted.
  • Buffered task delivery. PerformanceObserver delivers through a queued task as well, batching entries that accumulated since the last delivery, and it may be deferred further while the page is busy.

The consequence is that three pieces of code which all "react to the element changing" can observe three different states of the DOM and paint in three different frames. The IntersectionObserver API deep dive explains the geometry that is computed; here the question is only where in time the answer arrives.

One Frame, Four Observer Delivery PointsA single sixteen millisecond frame drawn as lanes. The script lane runs a task, then its microtasks, where MutationObserver callbacks fire. The rendering lane runs requestAnimationFrame callbacks, then style and layout, then ResizeObserver callbacks and the intersection computation, then paint. A second task after paint delivers IntersectionObserver entries, and a later task delivers PerformanceObserver entries.One frame at 60 Hz — where each observer's callback landstask + microtasksyour taskMutationrendering stepsrAFstyle+layoutResizeIO calcpaintnext tasksIO0ms2ms4ms6ms8ms10ms12ms14ms16mspaint startsMutationObserver sees the DOM before layout; ResizeObserver sees it after layout but before paint; IntersectionObserver hearsabout it after paint.

Understanding the diagram above removes most of the mystery from observer bugs. A class toggled from an IntersectionObserver callback cannot affect the frame in which the intersection was measured, because that frame has already been painted. A size written from a ResizeObserver callback can affect the current frame, which is exactly why the browser has to guard against infinite loops — the subject of fixing "ResizeObserver loop limit exceeded".

Spec / Signature Reference Table

The table below summarises the scheduling contract for each family as the HTML and CSSOM specifications define it. "Frame of effect" means the earliest frame in which a DOM write made inside the callback can be painted.

Observer Queued by Callback runs Sees layout? Frame of effect for writes
MutationObserver first mutation in a task microtask checkpoint, same task no — layout is stale same frame
ResizeObserver update the rendering, after layout inside rendering steps, before paint yes, fresh same frame (triggers re-layout)
IntersectionObserver update the rendering, after layout queued task, after paint yes, at computation time next frame
PerformanceObserver entry creation (buffered) queued task, may be idle-deferred n/a whenever convenient
requestAnimationFrame explicit call rendering steps, before style/layout stale until you read it same frame

Two properties in this table are easy to miss. First, ResizeObserver is the only observer whose callback runs with fresh layout and before paint, which makes it the correct place for measure-then-adjust work. Second, IntersectionObserver entries carry a time field — a DOMHighResTimeStamp taken when the intersection was computed, not when your callback runs — so comparing entry.time with performance.now() inside the callback tells you how late the delivery was.

Step-by-Step Implementation

The fastest way to internalise the model is to instrument it. The following steps build a small harness that logs each delivery point with its timestamp, so you can watch the ordering in your own browser.

Step 1: Stamp every phase with a shared clock

TypeScript
type Phase = 'task' | 'microtask' | 'mutation' | 'raf' | 'resize' | 'intersection' | 'performance';

interface Stamp { phase: Phase; t: number; frame: number }

const stamps: Stamp[] = [];
let frame = 0;

// Count frames with a rAF loop so every stamp can be tied to a frame number.
function tick(): void { frame++; requestAnimationFrame(tick); }
requestAnimationFrame(tick);

function stamp(phase: Phase): void {
  stamps.push({ phase, t: performance.now(), frame });
}

Each stamp records both the high-resolution time and the frame counter. Timestamps alone are misleading because two callbacks 0.3 ms apart can still land in different frames.

Step 2: Attach one of each observer to the same element

TypeScript
const box = document.querySelector<HTMLElement>('#probe')!;

new MutationObserver(() => stamp('mutation')).observe(box, { attributes: true });
new ResizeObserver(() => stamp('resize')).observe(box);
new IntersectionObserver(() => stamp('intersection')).observe(box);

new PerformanceObserver(() => stamp('performance'))
  .observe({ type: 'layout-shift', buffered: false });

Observing the same element with every family is what makes the comparison fair: the only variable left is the scheduling model.

Step 3: Trigger one change that every observer can see

TypeScript
document.querySelector('#go')!.addEventListener('click', () => {
  stamp('task');
  queueMicrotask(() => stamp('microtask'));
  requestAnimationFrame(() => stamp('raf'));
  box.style.width = box.offsetWidth === 200 ? '320px' : '200px'; // resize + attribute
  box.style.transform = 'translateY(0)';                           // no-op for layout
});

Changing the inline width writes the style attribute (a mutation), changes the box size (a resize) and can change the intersection ratio if the element straddles the viewport edge.

Step 4: Print the ordering after two frames

TypeScript
function report(): void {
  const base = stamps[0]?.t ?? 0;
  console.table(stamps.map((s) => ({ phase: s.phase, ms: +(s.t - base).toFixed(2), frame: s.frame })));
  stamps.length = 0;
}
document.querySelector('#go')!.addEventListener('click', () => {
  requestAnimationFrame(() => requestAnimationFrame(report));
});

In Chromium, Firefox and Safari the printed order is consistent: task, mutation and microtask (in queue order), raf, resize, then intersection one frame number later, with performance last. If your own output differs, something in your page is forcing an early layout — which is itself worth knowing.

The Probe Harness, Step by StepFour stacked steps. Stamp every phase against performance now and a frame counter. Attach all four observer families to one element. Trigger a single change that each can see. Print the stamps after two frames so every delivery has arrived.1Shared clockRecord performance.now() and a rAF frame counter for every callback, so orderingand frame boundaries are both visible.2One of each observerMutation, Resize, Intersection and Performance observers all watch the sameelement, so only the scheduling model differs.3One triggering changeA click handler changes the inline width, which is a mutation, a resize and possibly anintersection change at once.4Report two frames laterA double rAF waits until IntersectionObserver's post-paint task has run beforeprinting the table.

Threshold / Configuration Variants

Configuration options do not change which scheduling model an observer uses, but several of them change how often the callback is queued and how much work it carries. The table maps the options that matter for timing.

Option Observer Timing effect
subtree: true MutationObserver more records per microtask, same delivery point
attributeOldValue: true MutationObserver larger records; allocation cost inside the microtask
box: 'device-pixel-content-box' ResizeObserver extra rounding pass during rendering steps
threshold: [0, 0.25, 0.5, 0.75, 1] IntersectionObserver more crossings, so more post-paint tasks while scrolling
delay: 100 with trackVisibility IntersectionObserver v2 entries rate-limited to one per 100 ms per target
buffered: true PerformanceObserver first delivery includes history, so the first task is larger
durationThreshold: 16 PerformanceObserver (event) fewer, larger entries; delivery point unchanged

The delay option on IntersectionObserver v2 is the only one that directly throttles delivery. Every other lever reduces the number of reasons to deliver rather than the delivery rate.

Scheduling Model by Observer and OptionA grid showing, for each observer family, whether its callback sees fresh layout, whether writes made in it paint in the same frame, and whether it can loop. MutationObserver sees stale layout, paints in the same frame and cannot loop through layout. ResizeObserver sees fresh layout, paints in the same frame and can loop, which the browser caps. IntersectionObserver sees layout as of computation, paints next frame and cannot loop. PerformanceObserver has no layout relationship.Fresh layout?Writes paint this frame?Loop riskMutationObserverno, staleyesnone via layoutResizeObserveryesyesyes, depth-cappedIntersectionObserveras computednext framenonePerformanceObservernot applicablenot applicablenone

Edge Cases & Gotchas

Forced layout moves the goalposts. Reading offsetWidth or getBoundingClientRect() inside a MutationObserver callback forces a synchronous layout. That does not make ResizeObserver fire early — it still waits for the rendering steps — but it does mean you paid for layout twice in one frame. The batching reads and writes guide shows how to avoid the double cost.

Background tabs skip rendering entirely. When a tab is hidden, browsers stop running the update-the-rendering steps. requestAnimationFrame, ResizeObserver and IntersectionObserver all go quiet; MutationObserver keeps firing because it is tied to DOM changes, not frames. Code that assumes "a mutation will be followed by a resize callback" stalls in a background tab and then receives a burst when the tab becomes visible.

Throttled frames on low-power mode. Safari on iOS in Low Power Mode and several Android browsers cap rendering at 30 Hz. Every rendering-step observer halves its maximum delivery rate. Nothing breaks, but animations driven from IntersectionObserver callbacks look twice as coarse.

Iframes run their own rendering steps. A cross-origin iframe may be throttled or skipped while off-screen, so an observer inside it can be several frames behind the parent. The iframe troubleshooting guide covers the visible symptom.

The first observation is special. Calling observe() on either rendering-step observer schedules an initial notification for the next rendering opportunity, even when nothing has changed. That is why callbacks fire on initial render — or, when the element is detached, why they do not.

Hidden Tab vs Visible Tab DeliveryTwo columns. In a visible tab, mutations fire in microtasks, rendering steps run every frame, and resize and intersection callbacks arrive promptly. In a hidden tab, mutations still fire, but rendering steps are skipped, so requestAnimationFrame, ResizeObserver and IntersectionObserver are silent until the tab returns, when a single burst arrives.Visible tabMutationObserver fires at each microtaskcheckpointRendering steps run every frame at display rateResize and intersection callbacks arrive within aframeHidden tabMutationObserver still fires, tied to DOM changesRendering steps are skipped entirelyrAF, ResizeObserver and IntersectionObserver gosilentOne burst of entries arrives when the tab is shownagain

Framework Integration Patterns

Frameworks add their own scheduling layer on top of the browser's, and the interaction is where most timing bugs come from.

React batches state updates and commits them in a task or microtask depending on the trigger. An IntersectionObserver callback that calls setState produces a render that commits after the post-paint task — so a visibility-driven class change is always at least one frame behind the scroll that caused it. For work that must land in the same frame, move the measurement into a ResizeObserver callback or apply the change imperatively to the element through a ref:

TypeScript
import { useEffect, useRef } from 'react';

export function useSameFrameWidth<T extends HTMLElement>(apply: (el: T, width: number) => void) {
  const ref = useRef<T>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      // Runs after layout, before paint: an imperative write here lands this frame.
      apply(el, entry.contentBoxSize[0].inlineSize);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [apply]);
  return ref;
}

Vue flushes component updates in a microtask by default (flush: 'pre'). A watcher reacting to a ref that an IntersectionObserver callback set will run in that callback's task, and the DOM patch follows in the same microtask checkpoint — still one frame behind the measurement. The Vue composables topic covers flush: 'post' for reads that need the patched DOM.

Angular runs change detection after every task that zone.js intercepts, which includes observer callbacks. A busy IntersectionObserver therefore triggers application-wide change detection on every scroll crossing unless the observer is created outside the zone.

Choosing the Observer by When You Need the Answer

Most teams pick an observer by what it measures and then fight its timing. It is often simpler to invert the choice: decide when the answer must be available, then pick the mechanism whose delivery point matches.

You need to react before the next paint, with fresh geometry. Use ResizeObserver. It is the only callback that runs after layout and before paint. Resizing a canvas backing store, choosing a compact layout for a card, or clamping a popover to its container all belong here. The price is discipline: every write must either target a deeper element or be idempotent, or the loop guard will push work to the next frame.

You need to know about structural change as soon as it happens, geometry irrelevant. Use MutationObserver. It is the earliest notification the platform offers, and it batches naturally, so a framework re-render that touches two hundred nodes produces one callback. Keep the callback to bookkeeping — registering new targets with other observers, updating counts — and never read layout there.

You need to know that something became visible, and a frame of latency is acceptable. Use IntersectionObserver. Almost every product use — lazy loading, analytics impressions, pausing media, starting reveal animations — tolerates a frame of lag, which is why it is the most widely used of the four. When the effect must stay pinned to the scroll offset, reach for CSS (position: sticky, scroll-driven animations) instead.

You need to know what the page cost, eventually. Use PerformanceObserver. Its entries describe work that already happened; delivering them late is harmless and keeps the measurement from adding to the cost it measures.

Pick the Observer by When the Answer Is NeededA decision chain. If the reaction must land before the next paint using fresh geometry, use ResizeObserver. Otherwise, if you need structural change immediately and geometry does not matter, use MutationObserver. Otherwise, if visibility is the question and a frame of lag is fine, use IntersectionObserver. Otherwise, if the effect must track scroll exactly, use CSS sticky positioning or a scroll-driven animation. Anything left is measurement, which belongs to PerformanceObserver.Must the reaction paint this frame, using freshlayout?ResizeObserver — runs after layout, before paintyesnoIs it structural change, with geometryirrelevant?MutationObserver — microtask, earliest possibleyesnoIs it visibility, with a frame of lag acceptable?IntersectionObserver — post-paint taskyesnoMust it track the scroll offset exactly?CSS sticky or a scroll-driven animation, no callbackat allyesnoMeasuring what already happened: PerformanceObserver, delivered whenever the page is idle.

Measuring Timing in the Field

Lab traces show the ordering on your machine. Real users run on slower CPUs, busier pages and throttled frame rates, and the delivery gaps that matter only show up there. A small amount of field instrumentation turns "it flickers on some phones" into a number.

TypeScript
interface DeliveryStats { count: number; over16: number; over50: number; max: number }

const stats: DeliveryStats = { count: 0, over16: 0, over50: 0, max: 0 };

export function trackDelivery(entries: IntersectionObserverEntry[]): void {
  const now = performance.now();
  for (const e of entries) {
    const lag = now - e.time;           // computation → callback
    stats.count++;
    if (lag > 16) stats.over16++;
    if (lag > 50) stats.over50++;
    stats.max = Math.max(stats.max, lag);
  }
}

// Report once, when the page is being hidden — the last reliable moment.
addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden' || stats.count === 0) return;
  navigator.sendBeacon('/rum/observer-delivery', JSON.stringify(stats));
});

Call trackDelivery(entries) at the top of any intersection callback you care about. The ratio of over50 to count is a direct measure of how often the main thread was too busy to deliver promptly, and it correlates closely with poor interaction latency — the same congestion delays input handlers. Pair it with the long-task correlation technique to find what was occupying the thread.

For ResizeObserver there is no time field, but counting callbacks per frame with a rAF-driven frame counter reveals loop iterations in the field: more than two per frame during a resize means a callback is writing to its own depth.

Debugging Checklist

  • Record a trace in the Performance panel and find the Run Microtasks block that contains your MutationObserver
  • Look for your ResizeObserver
  • Confirm IntersectionObserver
  • Compare entry.time with performance.now()
  • Check document.visibilityState
  • Search the callback for layout reads (offsetHeight, getBoundingClientRect, getComputedStyle
TypeScript
// Paste into the console to measure delivery lag for any intersection observer.
const lag = new IntersectionObserver((entries) => {
  const now = performance.now();
  for (const e of entries) console.log(e.target, `lag ${(now - e.time).toFixed(1)} ms`);
}, { threshold: [0, 1] });
document.querySelectorAll('img, section').forEach((el) => lag.observe(el));

FAQ

Does IntersectionObserver run before or after paint?

The intersection is computed during the rendering steps, before paint, but the callback is delivered through a queued task, so your code runs after the frame containing that geometry has been painted. Writes made in the callback appear in the next frame at the earliest.

Why is MutationObserver faster than the others?

It is not faster so much as earlier. It is delivered at the microtask checkpoint of the task that caused the mutation, before any style or layout work, which is why it cannot tell you anything about geometry and why reading layout inside it forces an extra synchronous layout.

Can a ResizeObserver callback cause another ResizeObserver callback in the same frame?

Yes. If the callback changes the size of an observed element that sits deeper in the tree than the shallowest element processed so far, the browser re-runs layout and delivers again within the same frame. When it can no longer make progress it stops and reports the loop-limit error instead.

What does entry.time actually measure?

For IntersectionObserver entries it is the high-resolution timestamp at which the intersection was computed, relative to the time origin of the document. Subtracting it from performance.now() inside the callback gives the delivery delay, which is a useful proxy for main-thread congestion.

Do observers keep firing in a background tab?

MutationObserver does, because DOM changes can still happen. ResizeObserver, IntersectionObserver and requestAnimationFrame do not, because the browser skips the rendering steps for hidden documents. Expect a burst of entries when the tab becomes visible again.

Where does PerformanceObserver fit in the frame?

Nowhere fixed. Entries are buffered as they are created and delivered in a queued task, which browsers may postpone while the page is busy. That is deliberate: the observer reports on work that already happened, and delivering it urgently would add to the very cost it measures.

Does requestAnimationFrame run before or after observers?

rAF callbacks run first in the rendering steps, before style and layout. ResizeObserver runs after layout in the same steps, and IntersectionObserver entries are computed after that and delivered in a later task. MutationObserver has usually already run, at the microtask checkpoint of the task that made the change.


↑ Back to Core Observer Fundamentals & Browser APIs