MutationObserver re-triggers on your own injected DOM — guard against it

TIL a MutationObserver watching a third-party page fires again on the elements your own extension code just injected, causing an infinite injection loop unless the injection function is idempotent.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

Today I learned that a MutationObserver watching childList/subtree on a page you don't control will happily fire again when your own injected UI is the thing that just changed the DOM. Without a guard, that's an infinite loop: inject → observer fires → the new node looks like fresh host content → inject again.

inject-dashboard.ts
const DASHBOARD_ID = "dispatcher-dashboard";
 
function injectDashboard() {
  // Idempotency check — bail if we already injected it.
  if (document.getElementById(DASHBOARD_ID)) return;
 
  const dashboard = document.createElement("div");
  dashboard.id = DASHBOARD_ID;
  dashboard.textContent = "Pending: 0";
  document.body.appendChild(dashboard);
}
 
const observer = new MutationObserver(() => injectDashboard());
observer.observe(document.body, { childList: true, subtree: true });
 
injectDashboard();

The getElementById check inside injectDashboard is what breaks the loop. The observer callback can fire as often as it wants — every re-entrant call is a cheap no-op instead of a fresh appendChild that triggers the observer all over again.

Tip

observer.disconnect() before your own writes and observer.observe() again after also works, but it's more code for the same result, and it's easy to forget the re-observe() call on an early return. An idempotent injection function is simpler and harder to get wrong.

This came up while injecting an operational dashboard into a dispatch platform's page: the host app re-renders parts of its UI on every new order, so the observer fires constantly. Without the guard, the dashboard flickered and CPU usage climbed during busy shifts — a single if fixed both.