Pass attributeFilter: ['aria-expanded', 'data-state'] (with attributes: true implied) so the observer only records the attributes you care about, add attributeOldValue: true to receive the previous value, and compare old and new before reacting, because frameworks often rewrite attributes with the same value.
Problem / Scenario Context
A design system's accordion toggles aria-expanded on its buttons. An analytics layer wants to record every expand and collapse without touching the accordion's code, so it adds a MutationObserver on document.body with { attributes: true, subtree: true }. The callback fires hundreds of times per second during scroll: the site's sticky header updates style on every frame, carousels rewrite class, and a framework re-renders data-* attributes. Filtering in the callback works but the page spends measurable time building and discarding records nobody reads.
Filtering at the source is cheaper than filtering in the callback. The MutationObserver DOM Change Tracking topic introduces the options; this page focuses on attributes.
Mechanics Explanation
When an attribute is set on an observed node (or, with subtree: true, a descendant), the engine checks each interested observer's options before creating a record:
attributes: true— record attribute mutations at all. It is implied whenattributeFilterorattributeOldValueis given.attributeFilter: string[]— record only mutations whose attribute local name is in the list. Everything else is dropped before aMutationRecordis allocated.attributeOldValue: true— include the value before the change inrecord.oldValue.
A record is created for every setAttribute call that targets a filtered attribute, even if the new value equals the old one. Frameworks that re-apply attributes during reconciliation, or code that sets aria-expanded="false" defensively on every render, therefore produce records without real changes. Comparing oldValue with the current value is the only way to tell.
Records are delivered at the next microtask checkpoint, batched, as described in MutationObserver microtask timing.
Comparison Table: Attribute Observation Options
| Options | Records created for | oldValue |
Typical use |
|---|---|---|---|
{ attributes: true } |
every attribute change | no | debugging only |
{ attributes: true, attributeOldValue: true } |
every attribute change | yes | undo stacks, auditing |
{ attributeFilter: ['aria-expanded'] } |
only aria-expanded |
no | react to state, read current value |
{ attributeFilter: ['aria-expanded'], attributeOldValue: true } |
only aria-expanded |
yes | detect real transitions |
… plus subtree: true |
same, for all descendants | as above | page-wide instrumentation |
Minimal Reproducible Example
let records = 0;
new MutationObserver((rs) => { records += rs.length; })
.observe(document.body, { attributes: true, subtree: true });
setInterval(() => { console.log('records/sec', records); records = 0; }, 1000);
Scroll a typical marketing page: the count runs into the hundreds per second, almost all style and class.
Production-Safe Solution
type ToggleHandler = (el: Element, expanded: boolean) => void;
export function watchDisclosureState(root: Element, onToggle: ToggleHandler): () => void {
const mo = new MutationObserver((records) => {
for (const r of records) {
// attributeFilter guarantees r.attributeName === 'aria-expanded'.
const el = r.target as Element;
const now = el.getAttribute('aria-expanded');
if (now === r.oldValue) continue; // re-applied, not changed
onToggle(el, now === 'true');
}
});
mo.observe(root, {
subtree: true,
attributeFilter: ['aria-expanded'],
attributeOldValue: true,
});
return () => {
// Deliver anything still pending before disconnecting, so no toggle is lost.
const pending = mo.takeRecords();
mo.disconnect();
for (const r of pending) {
const el = r.target as Element;
const now = el.getAttribute('aria-expanded');
if (now !== r.oldValue) onToggle(el, now === 'true');
}
};
}
// Analytics without touching the accordion code:
const stop = watchDisclosureState(document.body, (el, expanded) =>
queueAnalytics({ id: el.id, expanded }));
declare function queueAnalytics(e: { id: string; expanded: boolean }): void;
Two details matter. When several records for the same element arrive in one batch, comparing each record's oldValue with the current value is not quite right — the current value reflects the last change, not the change this record describes. For a toggle that is usually acceptable (you learn the final state), but if you need every transition, compare consecutive records' oldValues instead. And the teardown uses takeRecords() so that a toggle made just before the component unmounts is not dropped — the technique covered in using takeRecords before disconnect.
Reconstructing Every Transition
For auditing or undo, you need each change in order, not just the final state. Within a batch, records for the same target and attribute appear in the order they happened, and each record's oldValue is the value before that change. The value after a change is therefore the next record's oldValue, or the current attribute value for the last record:
function transitions(records: MutationRecord[]): Array<{ el: Element; name: string; from: string | null; to: string | null }> {
const out: Array<{ el: Element; name: string; from: string | null; to: string | null }> = [];
for (let i = 0; i < records.length; i++) {
const r = records[i];
const next = records.slice(i + 1).find((n) => n.target === r.target && n.attributeName === r.attributeName);
const to = next ? next.oldValue : (r.target as Element).getAttribute(r.attributeName!);
if (to !== r.oldValue) out.push({ el: r.target as Element, name: r.attributeName!, from: r.oldValue, to });
}
return out;
}
The find makes this quadratic for huge batches; for attribute filters with a handful of names that is irrelevant, and a Map keyed by target and name fixes it if it ever matters.
Verification Steps
- Count records per second during scroll before and after adding the filter.
- Toggle an accordion and confirm exactly one callback invocation with the correct state.
- Force a framework re-render that re-applies
aria-expandedwith the same value and confirm no toggle is reported. - Unmount right after a toggle and confirm the teardown still reports it via
takeRecords(). - Toggle twice quickly in one task and check the transition reconstruction reports both changes.
Common Mistakes to Avoid
attributes: truewithsubtree: trueon the body. Hundreds of records per second you immediately discard.- Assuming a record means a change. Setting an attribute to its current value still creates one.
- Reading
getAttributefor every record in a multi-change batch. You see only the latest value. - Filtering
classto detect one class. Every class change creates a record; prefer a dedicated attribute ordata-state.
FAQ
Do I still need attributes: true when using attributeFilter?
No. Supplying attributeFilter or attributeOldValue implies attributes: true. Passing attributes: false alongside them throws a TypeError.
Does attributeFilter work with namespaced attributes?
It matches the attribute's local name without a namespace. For namespaced attributes such as xlink:href, filter on href and check record.attributeNamespace in the callback.
Why do I get a record when the value did not change?
Because the specification queues a record for every set operation on an observed attribute, not only for changes. Frameworks and defensive code often re-apply values, so compare oldValue with the new value.
Can I watch a CSS custom property change?
Only if it is set through the style attribute, and then you would observe style, which is noisy. Prefer reflecting the state you care about into a data attribute and filtering on that.
Can one observer watch different attributes on different elements?
Yes. Call observe once per target with its own options; a MutationObserver keeps separate registrations per node. Records for all of them arrive in the same callback, so check record.target and record.attributeName to route them.
Is attributeFilter faster than filtering in the callback?
Yes. Filtered-out mutations never allocate a record or schedule a delivery, so the cost is a name comparison at mutation time instead of an object per mutation plus callback work.
Related
- MutationObserver Performance with subtree: true — the cost of wide observation
- Detecting Added and Removed Nodes with MutationObserver — childList records
- Throttling MutationObserver Callbacks with Microtask Batching — when records still arrive fast
↑ Back to MutationObserver DOM Change Tracking