Sometimes the element you need does not exist when your script runs.
That happens often with third-party tools:
- warranty modals
- chat widgets
- review widgets
- financing buttons
- app-injected Shopify blocks
- tracking consent banners
- lead forms
The page loads. Your JavaScript runs. You query the DOM. The element is not there yet. A few seconds later, a third-party script injects the HTML and your event listener never attaches.
This is exactly the kind of problem MutationObserver solves.
The wrong fix: arbitrary timeouts
The quick fix is usually this:
setTimeout(() => {
document.querySelector("[data-modal-trigger]").addEventListener("click", openModal);
}, 2000);
That might work on your machine. It will fail when:
- the third-party script loads slower
- the network is delayed
- the user interacts early
- the app changes injection timing
- the target element is replaced later
Timeouts guess. Observers watch.
The better pattern
Use MutationObserver to watch for the element.
const attachModalListener = () => {
const trigger = document.querySelector("[data-modal-trigger]");
if (!trigger || trigger.dataset.listenerAttached === "true") {
return false;
}
trigger.addEventListener("click", () => {
document.body.classList.add("modal-open");
});
trigger.dataset.listenerAttached = "true";
return true;
};
if (!attachModalListener()) {
const observer = new MutationObserver(() => {
if (attachModalListener()) {
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
This pattern does four important things:
- It tries immediately.
- It watches for later DOM changes.
- It avoids attaching duplicate listeners.
- It disconnects once the job is done.
When to keep observing
Sometimes you should not disconnect.
Keep observing when:
- the third-party app repeatedly replaces the element
- product forms re-render after variant changes
- filters rebuild the product grid
- cart drawers replace internal markup
- SPA-like navigation changes page sections
In those cases, make your listener attachment idempotent. The code should be safe to run many times.
Event delegation can be even simpler
If the click bubbles, event delegation may be cleaner:
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-modal-trigger]");
if (!trigger) return;
document.body.classList.add("modal-open");
});
This works even if the element is added later, because the listener is attached to document.
Use delegation when possible. Use MutationObserver when you need to react to the element appearing, read injected data, initialize a widget, or modify the injected markup.
Practical debugging checklist
When a listener does not fire:
- Confirm the element exists when your script runs.
- Confirm the element is not replaced after your listener attaches.
- Check whether the interaction happens inside an iframe.
- Check whether the third-party script stops propagation.
- Try event delegation.
- Use
MutationObserverwhen the DOM is injected or replaced.
The key is to stop assuming page load means DOM complete. On modern websites, especially Shopify and WordPress sites with third-party tools, the DOM keeps changing.
MutationObserver gives you a clean way to work with that reality.