Observer bugs are usually performance bugs in disguise — a callback that forces layout, a pool that never releases targets, a batch that blocks input — and they are invisible until you know where observers show up in the browser's tools. This topic is the map.
Concept Framing
Most of the Performance Optimization & Memory Management section prescribes techniques: pool your observers, batch reads and writes, schedule work by priority. Each of those has a cost it claims to reduce, and the only way to know whether a page needs it — and whether the change worked — is to measure. Observers make that harder than ordinary code for three reasons.
Their callbacks are not in your call stack. A click handler appears in a trace under the event that triggered it. An observer callback appears under a browser-internal task — "Fire IntersectionObserver callbacks", "Deliver ResizeObserver notifications", or inside "Run Microtasks" for MutationObserver — with no user action nearby to anchor it.
Their cost is often indirect. A ResizeObserver callback that writes a style may take 0.5 ms of JavaScript and cause 30 ms of layout in the same frame. Looking only at script time misses the real cost.
Their memory is held by the browser. An observer instance keeps its targets and its callback alive through internal references that do not appear as ordinary JavaScript properties. Leaks show up in heap snapshots under retainer paths that are unfamiliar until you have seen them once.
The rest of this page walks through each tool with what to look for, and the four guides below go deep on the most common investigations.
Spec / Signature Reference Table
The tools and APIs used throughout this topic:
| Tool / API | Reveals | Where observers appear |
|---|---|---|
| Performance panel, Main track | script, style, layout, paint per task | tasks named after the observer type; ResizeObserver inside rendering steps |
| Bottom-Up / Call Tree tabs | self time per function | your callback function by name |
| Layout Shift and Rendering tabs | shifts, paint flashing | effects of callback writes |
| Memory panel, heap snapshot | retained objects and retainer paths | IntersectionObserver, ResizeObserver instances, IntersectionObserverEntry arrays |
| Memory panel, allocation timeline | allocations over time | entry objects allocated per batch |
performance.mark() / measure() |
named intervals | your own labels in the Timings track |
PerformanceObserver (measure, long-animation-frame) |
the same data, in the field | script attribution by invoker |
console.count() / console.timeStamp() |
ad-hoc counts and markers | quick checks without instrumentation |
Step-by-Step Implementation
This workflow profiles a page with observer-driven behaviour from scratch. Use a production build, a clean browser profile without extensions, and CPU throttling to approximate a mid-range phone.
Step 1: Label your callbacks before recording
export function label<T extends (...args: never[]) => void>(name: string, fn: T): T {
return ((...args: Parameters<T>) => {
performance.mark(`${name}:start`);
try { fn(...args); }
finally { performance.measure(name, `${name}:start`); }
}) as T;
}
const io = new IntersectionObserver(label('feed:io', (entries) => { /* … */ }));
Named function expressions also help: the Bottom-Up view shows function names, and an anonymous arrow shows as "(anonymous)".
Step 2: Record the interaction that exercises the observer
Open the Performance panel, set CPU throttling to 4× or 6×, start recording, perform the scroll or resize, and stop. Keep recordings short — five to ten seconds — so the flame chart stays readable.
Step 3: Find the callbacks and their children
In the Timings track, each measure is a labelled bar; click one and the Main track highlights the corresponding time. Expand the task: script time is yellow, style and layout purple, paint green. A purple Layout nested inside your callback is a forced synchronous layout; the task will usually carry a warning triangle with "Forced reflow is a likely performance bottleneck."
Step 4: Check memory after a navigation cycle
For single-page apps, take a heap snapshot, navigate away and back several times, force garbage collection, and take another. Filter the comparison view by Observer — instance counts that grow with each cycle are leaks. The heap snapshot diffing guide walks through the retainer paths.
Step 5: Carry the measures into the field
The same performance.measure entries can be observed in production and summarised:
new PerformanceObserver((list) => {
for (const m of list.getEntriesByType('measure')) {
if (m.name.includes(':io') || m.name.includes(':ro')) record(m.name, m.duration);
}
}).observe({ type: 'measure', buffered: true });
declare function record(name: string, ms: number): void;
Threshold / Configuration Variants
What counts as "too slow" depends on the observer and on how often it runs. These are practical thresholds for a mid-range phone at 4× CPU throttling on a desktop machine.
| Finding | Acceptable | Investigate | Fix now |
|---|---|---|---|
| IntersectionObserver callback, per batch | < 5 ms | 5–20 ms | > 20 ms, or any forced layout |
| ResizeObserver callback, per frame | < 2 ms | 2–8 ms | > 8 ms, or loop errors |
| MutationObserver callback | < 2 ms | 2–10 ms | > 10 ms inside input handlers |
| Forced layouts inside callbacks | 0 | 1 per batch | more than 1 per batch |
| Observer instances after 5 navigations | constant | slowly rising | rising by a fixed amount each cycle |
| Entries per batch while scrolling | < 20 | 20–100 | > 100 (thresholds too dense?) |
Edge Cases & Gotchas
Development builds lie. React, Vue and Angular development builds add checks that can multiply callback time several-fold. Profile production builds, with source maps so function names survive minification.
Extensions add observers. Ad blockers, password managers and accessibility extensions create their own MutationObservers and IntersectionObservers on your page. Profile in a clean profile or guest window, or you will chase callbacks that are not yours.
DevTools itself changes timing. Having the Elements panel open with a large DOM slows mutations, because DevTools observes the DOM to keep its tree view current. Close it while recording.
Heap snapshots need forced GC. A snapshot taken without collecting garbage shows objects that are unreachable but not yet collected. The snapshot action forces a collection in Chromium, but comparing across snapshots still requires a stable baseline — take one warm-up snapshot and discard it.
Throttling is not a phone. CPU throttling slows JavaScript and layout proportionally but does not model GPU, thermal throttling or memory pressure. Confirm important findings with remote debugging on a real device.
A Worked Investigation
Profiling is easier to learn from an example than from a list of tools. Here is a condensed version of a real investigation on a documentation site that stuttered while scrolling on mid-range Android phones.
Symptom. Scrolling long reference pages dropped frames, but only after the page had been open for a while and the user had navigated between several pages in the single-page shell.
First trace. A five-second scroll at 4× CPU throttling showed tasks labelled with the site's highlightActive function after most paints — the scroll-spy callback — each taking 8–12 ms. Expanding one revealed purple Recalculate Style nested inside: the callback removed an active class from every link in the table of contents and added it to one, invalidating style for the whole sidebar on every crossing.
Second finding. The batches were large: 40–60 entries per callback on a page with only 25 headings. Filtering the heap snapshot for IntersectionObserver showed six instances, one per page visited — each still observing headings from pages that had been navigated away from, now detached.
Fixes. The callback was changed to touch only the previously active link and the new one, removing the style recalculation. The component's teardown was fixed to disconnect() on route change. Both changes were verified the same way they were found: a trace showing 1 ms callbacks with no nested purple, and a heap comparison showing one observer instance after seven navigations.
Field confirmation. User Timing measures around the callback, sampled at 5% of sessions, showed p75 dropping from 14 ms to under 2 ms over the following week.
The pattern is typical: the visible symptom (dropped frames) had two independent causes, one in time and one in memory, and each tool found one of them.
Framework Integration Patterns
Framework profilers complement the browser's. React DevTools' Profiler shows which components re-rendered because of a state update from an observer callback — useful when an intersection callback calls setState and triggers a wide re-render. Vue DevTools' performance timeline shows component render and patch times. Angular DevTools shows change-detection cycles, which is where an observer created inside the zone reveals itself: a cycle for every callback.
A small wrapper makes observer callbacks visible in all of them by name:
export function profiledObserver(name: string, cb: IntersectionObserverCallback, init?: IntersectionObserverInit) {
const wrapped: IntersectionObserverCallback = (entries, obs) => {
console.timeStamp?.(`${name}: ${entries.length} entries`); // marker in the trace
performance.mark(`${name}:start`);
cb(entries, obs);
performance.measure(`${name}`, `${name}:start`);
};
Object.defineProperty(wrapped, 'name', { value: `observer:${name}` });
return new IntersectionObserver(wrapped, init);
}
Debugging Checklist
FAQ
How do I find an IntersectionObserver callback in a Chrome trace?
Look for a task after a paint labelled along the lines of "Fire IntersectionObserver callbacks", or search the Bottom-Up view for your callback's function name. Adding a User Timing measure around the callback makes it appear as a labelled bar in the Timings track.
Why does my ResizeObserver callback show up inside a frame rather than as its own task?
Because it runs inside the rendering steps, between layout and paint. In the trace it appears within the frame's rendering work, which is also why its cost directly delays that frame's paint.
What does "Forced reflow is a likely performance bottleneck" mean for observers?
The callback read a layout property, such as offsetHeight or getBoundingClientRect, after something invalidated layout, forcing the browser to lay out synchronously. In observer callbacks this is usually avoidable, because the entry already carries the geometry you need.
Can heap snapshots show which element an observer is holding?
Yes. Select a leaked observer instance and inspect its retainers and retained objects; detached elements appear as "Detached HTMLDivElement" and similar entries, and their retainer path leads back through the observer's internal target list.
Is User Timing overhead a problem in production?
Marks and measures are cheap, but creating them for every callback on a busy page adds allocations and fills the performance buffer. Sample — instrument a fraction of sessions — or only measure callbacks that exceed a threshold.
How do I profile observers on iOS Safari?
Use Safari's Web Inspector over USB. Its Timelines tab records JavaScript and layout; observer callbacks appear as script entries with your function names. Heap snapshots are available in the JavaScript Allocations timeline.
How can I tell my observers apart from those created by third-party scripts?
Name your callbacks and wrap them with User Timing, then check the source URL of any unnamed callback in the trace's Summary pane. In the field, Long Animation Frame script entries include a sourceURL for each script, which separates your bundles from vendor tags without any instrumentation on their side.
Do observers show up in the Memory panel's allocation timeline?
Yes. Each callback batch allocates an array of entry objects, and the allocation timeline shows those as small blue spikes during scrolling. Spikes that stay blue — still alive after later garbage collections — mean the entries are being retained, usually because a callback stores them in state or an array.
What should I profile first on an unfamiliar page?
Scroll the longest page with the Performance panel recording and CPU throttled, then sort the Bottom-Up view by self time. Observer callbacks that appear near the top, or that carry nested Layout, are the first candidates. Memory comes second, and only for single-page apps where views are mounted and unmounted repeatedly.
Related
- Reading Observer Callbacks in the Chrome Performance Panel — the trace in detail
- Finding Observer Leaks with Heap Snapshot Diffing — memory investigations
- Measuring Callback Cost with User Timing Marks — lab-to-field instrumentation
- Spotting Forced Reflow Inside ResizeObserver Callbacks — the hidden layout cost
- Observer Pool Memory Profile in Single-Page Apps — a worked memory profile
↑ Back to Performance Optimization & Memory Management for Observer APIs