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.
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:
// 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:
- Set up. Production build, clean profile, CPU throttling 4×, screenshots on, and close the Elements panel.
- Mark the interaction. Call
console.timeStamp('scroll start')from the console just before scrolling; it drops a marker into the trace. - Find callbacks by name. Open Bottom-Up, group by Activity, and search for your callback names. Double-click to jump to the first occurrence.
- Classify each occurrence. Is it a separate task after Paint (IO), inside rendering (RO), or inside Run Microtasks (MO)?
- 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.
- 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.
- Correlate with frames. In the Frames track, find dropped or partially presented frames and see which callbacks ran in them.
// Handy console helpers while recording
console.timeStamp('scroll start');
performance.mark('scroll-start');
// …scroll…
performance.measure('scroll', 'scroll-start');
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.
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.
Related
- Spotting Forced Reflow Inside ResizeObserver Callbacks — the most common finding
- Measuring Callback Cost with User Timing Marks — labelling callbacks
- Correlating Long Tasks with Observer Callbacks — the same evidence in the field