In a Chrome trace, IntersectionObserver callbacks appear as their own task after a paint, ResizeObserver callbacks appear inside the frame's rendering work between Layout and Paint, and MutationObserver callbacks appear inside a "Run Microtasks" block at the end of the task that changed the DOM — find them there, then check what is nested underneath.

Problem / Scenario Context

A developer is asked to find out why a documentation site stutters while scrolling on Android. They record a trace, open the Main track, and see a wall of yellow and purple. Their code uses three observers — one for lazy images, one for a scroll-spy table of contents, one resizing code blocks — but nothing in the flame chart is labelled with those names. They cannot tell which blocks are theirs.

Observer callbacks are invoked by the browser, so the trace labels them by browser mechanism rather than by your intent. Once you know the shapes, they are easy to spot. This page is the visual companion to Profiling Observer Performance in DevTools.

Mechanics Explanation

Each observer type sits at a different point in the event loop — explained in the rendering pipeline topic — and the trace reflects exactly that:

IntersectionObserver. Intersections are computed during the rendering steps, but the callback is delivered in a separate task. In the Main track, look for a short task after a frame's paint, with a top-level event such as "Fire IntersectionObserver callbacks" (naming varies by Chrome version) and your function beneath it.

ResizeObserver. Delivered during the rendering steps. In the Main track it appears nested in the frame's rendering work, after Layout, labelled something like "ResizeObserver callback" or "Deliver ResizeObserver notifications", followed — if the callback changed layout — by another Layout before Paint.

MutationObserver. Delivered at the microtask checkpoint. It appears inside Run Microtasks at the end of whatever task mutated the DOM: an event handler, a timer, a framework render. Its cost is part of that task.

Underneath any of them, the colours tell the rest: yellow is script, purple is style and layout, green is paint. Purple nested under a callback means the callback forced layout synchronously.

Three Observers in One Frame of a TraceA simplified Main track across one frame and the start of the next task. A click handler task ends with a Run Microtasks block containing the MutationObserver callback. The rendering work then shows Layout, the ResizeObserver callback, a second Layout caused by it, and Paint. After paint, a separate task fires the IntersectionObserver callback.Main track, one frame (simplified)taskclick handlerMOrenderingLayoutROLayout 2Paintnext taskIO callback0ms4ms8ms12ms16ms20ms24ms

Comparison Table: Signatures in the Trace

Observer Parent in the flame chart Healthy shape Unhealthy shape
IntersectionObserver its own task after Paint thin yellow bar, < 5 ms wide bar; purple Layout nested inside; red long-task corner
ResizeObserver frame rendering work, after Layout thin bar, then no second Layout second and third Layout; "loop limit" console error
MutationObserver Run Microtasks of the mutating task small block at the end of the task large block that makes the parent task long
PerformanceObserver its own task, often in idle time rare, small frequent tasks during scroll (too many entry types)

Minimal Reproducible Example

To learn the shapes, profile a deliberately bad page and a good one side by side:

TypeScript
// Bad: reads layout in every entry, writes in between (forced reflow per entry).
const bad = new IntersectionObserver(function badCallback(entries) {
  for (const e of entries) {
    const h = (e.target as HTMLElement).offsetHeight;             // read
    (e.target as HTMLElement).style.minHeight = `${h + 1}px`;     // write
  }
});

// Good: uses the entry's rect, writes only.
const good = new IntersectionObserver(function goodCallback(entries) {
  for (const e of entries) {
    (e.target as HTMLElement).style.minHeight = `${Math.round(e.boundingClientRect.height)}px`;
  }
});

Named function expressions (badCallback, goodCallback) make each one findable by name in the Bottom-Up view.

Production-Safe Solution

A repeatable reading procedure for any trace:

  1. Set up. Production build, clean profile, CPU throttling 4×, screenshots on, and close the Elements panel.
  2. Mark the interaction. Call console.timeStamp('scroll start') from the console just before scrolling; it drops a marker into the trace.
  3. Find callbacks by name. Open Bottom-Up, group by Activity, and search for your callback names. Double-click to jump to the first occurrence.
  4. Classify each occurrence. Is it a separate task after Paint (IO), inside rendering (RO), or inside Run Microtasks (MO)?
  5. Look underneath. Any purple child is a forced style or layout. Click it; the Summary shows "Layout Forced" with the stack of the line that forced it.
  6. Check the parent task length. A red triangle in the task's corner means it exceeded 50 ms. If your callback is most of it, it is your long task.
  7. Correlate with frames. In the Frames track, find dropped or partially presented frames and see which callbacks ran in them.
TypeScript
// Handy console helpers while recording
console.timeStamp('scroll start');
performance.mark('scroll-start');
// …scroll…
performance.measure('scroll', 'scroll-start');

What Is This Block Under My Callback?A decision chain for reading a callback's children in the trace. If there is a purple Layout nested inside the callback, a layout read forced synchronous layout. Otherwise, if there is purple Recalculate Style, a style read or class change forced style recalculation. Otherwise, if the parent task has a red corner, the callback made a long task. Otherwise, the callback is only script time and should be judged by its duration.Purple Layout nested inside the callback?Forced synchronous layout — find the read in theSummary stackyesnoPurple Recalculate Style inside it?Forced style — a getComputedStyle read or classchurnyesnoRed corner on the parent task?Long task — split or defer the callback's workyesnoScript only: judge by duration against the callback's budget.

Reading ResizeObserver Loops

ResizeObserver has a distinctive failure shape. In a healthy frame you see Layout → RO callback → Paint. When a callback changes the size of observed elements, you see Layout → RO callback → Layout → RO callback → … within the same frame, each iteration deeper in the DOM tree, until either nothing changes or the browser gives up and logs the loop-limit error to the console.

A Healthy and a Looping Resize FrameSteps inside one frame of a looping ResizeObserver. Layout runs for the frame. The first callback writes a size to an observed element. Layout runs again. A second callback runs for the deeper element and writes again. A third layout follows, and only then does paint happen. A healthy frame would stop after the first callback.1LayoutThe frame's own layout pass.2RO callback, depth 0Writes a size to an observed child.3Layout againForced by the write; the child's size changed.4RO callback, depth 2Delivers the child, writes again, another Layout follows.5PaintFinally reached; a healthy frame gets here after step 2.

Each extra iteration is a full layout of the affected subtree. On complex pages that can be 10–30 ms per iteration, which is easy to miss if you only look at the callback's own script time. Count the Layout blocks inside a single frame: more than two during a resize is a sign that a callback is writing to its own depth, which the loop-limit guide explains how to fix.

Verification Steps

  • Confirm you can find each of your callbacks by name in the Bottom-Up view before trusting any conclusion.
  • Compare a before-and-after trace of the same interaction; the callback's total time and the number of nested Layouts should drop.
  • Check the Frames track for fewer dropped frames after the fix.
  • Repeat on a real device with remote debugging to confirm the throttled result.
  • Save the trace (the download button) and attach it to the pull request as evidence.

Common Mistakes to Avoid

  • Profiling with DevTools panels open that observe the DOM. The Elements panel slows mutations noticeably.
  • Anonymous callbacks. They show as "(anonymous)" and are hard to find; name them.
  • Judging a callback by script time alone. Nested Layout is often the real cost.
  • Long recordings. A minute of scrolling produces an unreadable flame chart; record five seconds.

FAQ

Why do I not see "IntersectionObserver" anywhere in the trace?

Chrome's event names change between versions and are often generic, such as a task with a function call beneath it. Search for your callback's function name instead, or add a User Timing measure around it so it shows in the Timings track.

What does a red triangle on a task mean?

The task took longer than 50 ms, the long-task threshold. If an observer callback accounts for most of it, that callback is blocking input while it runs.

How do I see which line forced a layout?

Click the purple Layout block nested inside the callback. The Summary pane shows "Layout Forced" with a stack trace; the topmost frame from your code is the line that read layout.

Can I profile observers in Firefox?

Yes, with the Firefox Profiler. Callbacks appear under their JavaScript function names, with layout and style markers ("Reflow", "Styles") in the marker chart. The same principles apply: look for reflow markers inside your callback's time range.

Why does the MutationObserver cost appear under my click handler?

Because it runs at the microtask checkpoint of the task that mutated the DOM — your click handler's task. From the browser's point of view, it is part of handling that click, which is why an expensive MutationObserver adds to interaction processing time.


↑ Back to Profiling Observer Performance in DevTools