skip to content

In a MutationObserver callback, every MutationRecord with type 'attributes' has oldValue === null. What did the observe() call leave out, and how do you also stop unrelated attributes from generating records?

level: juniorimportance: should knowfreq 38%

answer

  1. previous values are opt-in, not free
  2. the record names the attribute, not the value
  3. a filter array exists for attributes
  4. the flag has a characterData twin
  5. childList expresses "before" structurally

basics

~20 s

The observe() options omitted attributeOldValue: true, so the browser does not record previous values. Add it to populate oldValue, and add attributeFilter with an array of attribute names to record only the attributes you care about.

solid answer

~40 s

Capturing the previous value costs work, so `MutationObserver` only does it when asked. `oldValue` stays `null` unless the registration passed `attributeOldValue: true` (or `characterDataOldValue: true` for text mutations). Fix it with `observe(el, { attributes: true, attributeOldValue: true })`, and the record then carries the value the attribute held before the change; the *new* value is not in the record at all — read it back with `target.getAttribute(record.attributeName)`. To ignore attributes you do not care about, pass `attributeFilter: ['data-state', 'class']`; only those names produce records. Note that `oldValue` is always `null` on `childList` records — it is meaningful only for attribute and characterData mutations.

code

javascript · 13 lines
javascript
const el = document.createElement('div');
document.body.append(el);
el.setAttribute('data-state', 'idle');

const mo = new MutationObserver((records) => {
  for (const r of records) {
    console.log(r.attributeName, r.oldValue, '->', r.target.getAttribute(r.attributeName));
  }
});
mo.observe(el, { attributeOldValue: true, attributeFilter: ['data-state'] });

el.setAttribute('data-state', 'busy'); // logs: data-state idle -> busy
el.setAttribute('title', 'ignored');   // filtered out, no record

go deeper

for a junior

Remember that previous values are opt-in: pass attributeOldValue: true, and use attributeFilter when you only care about specific attribute names.

for a middle

Explain that the record identifies the attribute but not its new value, that the current value is read from record.target, and that the characterData flag is the symmetric case.

for a senior

Point out the cost angle — filtering at registration avoids allocating records at all — and the batching consequence that a live read-back may already reflect later writes.

for a principal

Frame it as a contract question: decide whether observation is even the right way to learn about state changes, versus owning an explicit event or state channel the writer publishes.

## Why oldValue is opt-in A `MutationRecord` describes a change that has already happened. For attribute and text mutations the browser could in principle keep the previous value, but doing so for every mutation on every observed node is pure overhead for the many observers that never look at it. So the DOM specification makes it a flag: `attributeOldValue: true` for attributes, `characterDataOldValue: true` for `CharacterData` nodes. Without the flag, `record.oldValue` is `null`. ```js const mo = new MutationObserver((records) => { for (const r of records) { console.log(r.attributeName, r.oldValue, r.target.getAttribute(r.attributeName)); } }); mo.observe(el, { attributes: true, attributeOldValue: true }); ``` ## The record tells you what changed, not what it became This trips people up as often as the flag does. An attribute record carries `attributeName`, `attributeNamespace` and (optionally) `oldValue` — there is no `newValue`. The current value is read from the live DOM through `record.target`. That has a consequence worth saying out loud in an interview: because delivery is asynchronous and batched, by the time you read `target.getAttribute(...)` you get the value *now*, which may be several mutations later than the record you are looking at. If you need an exact history, reconstruct it from the sequence of `oldValue`s rather than from repeated live reads. ## attributeFilter narrows what is recorded `attributeFilter` takes an array of local attribute names: ```js mo.observe(el, { attributes: true, attributeFilter: ['data-state', 'aria-expanded'] }); ``` Any other attribute write on that element produces no record at all — the filtering happens before allocation, so this is cheaper than receiving everything and skipping records in your callback. On a busy page where framework code rewrites `class` or `style` constantly, a filter is often the difference between a quiet observer and one that runs on every frame. ## The two convenience implications Passing `attributeOldValue` or `attributeFilter` without mentioning `attributes` implies `attributes: true`, so `observe(el, { attributeFilter: ['class'] })` is a valid registration. The same applies to `characterDataOldValue`, which implies `characterData: true`. But contradicting yourself is an error: `{ attributes: false, attributeOldValue: true }` throws a `TypeError` rather than being quietly ignored. ## characterData is the symmetric case For text mutations the flag is `characterDataOldValue: true`, and the record's `target` is the `Text` (or `Comment`) node itself; `oldValue` is the string it held before, and the current string is `target.data`. Remember that observing an element for text edits also needs `subtree: true`, because the mutation belongs to the child text node. ## childList records never have an oldValue `oldValue` is `null` on every `childList` record, and there is no flag that changes that. What was there before is expressed structurally instead: `removedNodes` holds the nodes that left, `addedNodes` the nodes that arrived, and `previousSibling`/`nextSibling` locate where the change happened in the child list. Those `removedNodes` entries are live references to detached nodes, which is why holding a large array of records for a long time also holds a chunk of detached DOM in memory. ## What a good answer sounds like "`oldValue` is null because the registration did not ask for it — add `attributeOldValue: true`. The record only names the attribute; I read the new value from `record.target`. And if I only care about a couple of attributes I pass `attributeFilter` so the rest never allocate records." That covers the flag, the missing `newValue`, and the cost control in three sentences.

  • Where do you get the attribute's new value from, given the record does not carry one?
    From the live DOM: `record.target.getAttribute(record.attributeName)`. Because records are delivered in batches after the fact, that read returns the value as of *now*, not as of the moment the record was created — if several writes to the same attribute were batched, every record reads back the same final value. When exact intermediate values matter, chain the `oldValue`s instead.
  • Is attributeFilter just a convenience, or does it actually save work?
    It saves real work. Filtering is applied when the mutation is queued, so attributes outside the list never allocate a `MutationRecord` and never enlarge the array your callback iterates. On pages where framework code rewrites `class` or `style` continuously, filtering to the one or two attributes you care about can cut the observer's record volume by orders of magnitude.

saying these in an interview costs you the question

  • Expects oldValue to be populated by default
  • Looks for a newValue property on the record
  • Thinks childList records can carry an oldValue
  • Passes attributes: false alongside attributeOldValue
  • Filters unwanted attributes inside the callback instead of via attributeFilter

context