DOM updates can arrive from fetch responses, third-party widgets, and component renders long after the initial HTML has loaded. MutationObserver lets browser JavaScript watch a specific part of the DOM and react only when matching child, attribute, or text changes occur.
A focused observer starts with one target node and a small options object. The callback receives MutationRecord entries after the browser batches matching changes, so code can read the changed type, target, added nodes, attribute name, and previous value without polling the page.
Keep the observation scope as narrow as the feature allows. Watching one container and a filtered attribute keeps callbacks predictable, and calling disconnect() after the needed change prevents later page updates from running stale observer code.
<section id="activity-feed" data-state="idle" aria-live="polite"> <p>No updates yet.</p> </section> <button type="button" id="add-update">Add update</button> <button type="button" id="after-disconnect">Change after disconnect</button> <pre id="mutation-output">No mutation records yet.</pre>
The data-state attribute gives the observer a small state change to filter, while the paragraph insertion proves childList mutations.
const feed = document.querySelector("#activity-feed"); const output = document.querySelector("#mutation-output"); const mutationLog = []; window.mutationLog = mutationLog; function renderMutationLog() { output.textContent = JSON.stringify(mutationLog, null, 2); }
Load observer.js after the target markup or use defer so the selectors can find the elements.
Related: How to load JavaScript with defer
const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { mutationLog.push({ type: mutation.type, target: mutation.target.id || mutation.target.nodeName.toLowerCase(), addedNodes: mutation.addedNodes.length, attributeName: mutation.attributeName, oldValue: mutation.oldValue }); } renderMutationLog(); if (feed.dataset.state === "ready") { observer.disconnect(); } });
disconnect() stops future callbacks after the expected state is reached. Call observe() again later only when the same observer should start watching again.
observer.observe(feed, { childList: true, attributes: true, attributeFilter: ["data-state"], attributeOldValue: true });
observe() must enable at least one of childList, attributes, or characterData. attributeFilter limits the attribute records to data-state, and attributeOldValue records the previous value.
document.querySelector("#add-update").addEventListener("click", () => { const item = document.createElement("p"); item.textContent = "Profile data loaded."; feed.append(item); feed.dataset.state = "ready"; });
The append creates a childList record, and the data-state assignment creates an attributes record in the same observer callback batch.
document.querySelector("#after-disconnect").addEventListener("click", () => { const item = document.createElement("p"); item.textContent = "This change happens after disconnect."; feed.append(item); feed.dataset.state = "ignored"; });
The visible log should show one childList record and one data-state attribute record.
[
{
"type": "childList",
"target": "activity-feed",
"addedNodes": 1,
"attributeName": null,
"oldValue": null
},
{
"type": "attributes",
"target": "activity-feed",
"addedNodes": 0,
"attributeName": "data-state",
"oldValue": "idle"
}
]
> mutationLog.length 2
The target receives the extra DOM change, but the disconnected observer no longer appends mutation records.