Use a MutationObserver to Handle DOM Nodes that Don’t Exist Yet
Tags: mutationobserver, polling, javascript
Published: 2023-02-21 23:34:07

---
title: Use a MutationObserver to Handle DOM Nodes that Don’t Exist Yet
published: true
date: 2023-02-13 05:00:00 UTC
tags: MutationObserver, polling, JavaScript
canonical_url: https://macarthur.me/posts/use-mutation-observer-to-handle-nodes-that-dont-exist-yet
---

Occasionally, you need to mess with a part of the DOM that doesn’t exist… yet.

There’s a variety of reasons the need could arise, but you might most often see it when dealing with third-party scripts that inject markup onto a page asynchronously. For example, I recently needed to update the UI whenever a user closed Google reCAPTCHA’s (v2) challenge. Responding to `blur` events like this isn’t officially supported by the tool, so I intended to wire up a listener myself. However, trying to access the node via something like `.querySelector()` yielded `null` because it hadn’t yet been rendered by the browser on page load, and I couldn’t know exactly when it’d happen.

To explore this a little more with a more contrived example, I’ve wired up a button to be mounted to the DOM after a random amount of time (between zero and five seconds). If I were to try to add an event listener to that button from the get-go, I’d get an exception.

```
// Simulating lazily-rendered HTML:
setTimeout(() => {
	const button = document.createElement('button');
  button.id = 'button';
  button.innerText = 'Do Something!';

  document.body.append(button);
}, randomBetweenMs(1000, 5000));

document.querySelector('#button').addEventListener('click', () => {
	alert('clicked!')
});

// Error: Cannot read properties of null (reading 'addEventListener')

```

Not surprising. All the code you see is being thrown onto the call stack and executed immediately (except, of course, `setTimeout()`’s callback), so by the time I try to access my button, all I find is `null`.

## You Could Try Polling

To get around this, it’s common to use polling, querying the DOM every so often until the node appears. You might’ve seen approaches like this using `setInterval` or `setTimeout`, like this example using recursion:

```
function attachListenerToButton() {
  let button = document.getElementById('button');

  if (button) {
    button.addEventListener('click', () => alert('clicked!'));
    return;
  }

	// If the node doesn't exist yet, try
	// again on the next turn of the event loop.
  setTimeout(attachListenerToButton);
}

attachListenerToButton();

```

Or, you might’ve come across a Promise-based approach that _feels_ a little more modern:

```
async function attachListenerToButton() {
  let button = document.getElementById('button');

  while (!button) {
		// If the node doesn't exist yet, try
		// again on the next turn of the event loop.
    button = document.getElementById('button');
    await new Promise((resolve) => setTimeout(resolve));
  }

  button.addEventListener('click', () => alert('clicked!'));
}

attachListenerToButton();

```

Either way, there are non-trivial costs to this sort of tactic — the primary one being performance. In both versions, removing `setTimeout()` would cause the script to run completely synchronously, blocking the main thread, along with any other task that needs to take place on it. No input events would be handled. Your tab would freeze. Chaos wouldn’t ensue.

Sticking a `setTimeout()` (or `setInterval`) in there kicks the next attempt onto the following iteration of the event loop, which allows other tasks to execute in the meantime. **But you’re still repeatedly hogging the call stack waiting for your node to appear.** Far less than ideal if you want your code to steward the event loop well.

You _could_ lessen the call stack bloat by increasing the interval by which you query (like every 200ms). But you then risk something unexpected occurring between the time the node appears and when your work is performed. For example, if you’re doing something like adding a click event listener, you don’t want the user to have the chance to click the element _before_ you’ve attached a listener several milliseconds later. Issues like this are probably few and far between, but they’ll certainly cause distress when you’re later debugging what might’ve gone wrong.

## MutationObserver(): A Tool Built for the Job

The [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) has been around for a bit now, and is [extremely well-supported in modern browsers](https://caniuse.com/mutationobserver). It has a straightforward purpose: do something when the DOM tree changes (including when a node gets inserted). But as a native browser API, you don’t have the same performance concerns as you would with polling. A basic set up for observing any changes within the `body` looks like so:

```
const domObserver = new MutationObserver((mutationList) => {
	// document.body has changed! Do something.
});

domObserver.observe(document.body, { childList: true, subtree: true });

```

Taking it a little further for our contrived example is fairly straightforward. Every time the tree has changed, we’ll query for the specific node. If exists, attach the listener.

```
const domObserver = new MutationObserver(() => {
  const button = document.getElementById('button');

  if (button) {
    button.addEventListener('click', () => alert('clicked!'));
  }
});

domObserver.observe(document.body, { childList: true, subtree: true });

```

The options we’re passing to `.observe()` are important. Setting `childList` to `true` makes the observer watch for changes to the node we’re targeting (`document.body`), and `subtree: true` will cause all descendants to also be monitored. Admittedly, the API here wasn’t tremendously easy (for me) to grok, and it’s [worth spending a bit of time thinking through](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe) before you use it for your own needs.

Regardless, this particular configuration is best for cases when you don’t know where the node might be injected. If you’re confident it’ll appear within a certain element, however, it’d be wise to target more narrowly.

## Important Step: Cleanup!

If we left the observer as it is, we’d be at risk for adding another click event listener to the same button after _every_ proceeding change to the DOM. You could remedy this by pulling click event callback into its own variable outside the MutationObserver’s callback (`.addEventListener()` won’t add a listener to a node with the same callback reference), but it’s far more intuitive to imperatively clean up the observer it’s once it’s no longer needed. There’s a nice method available on the observer to do that:

```
const domObserver = new MutationObserver((_mutationList, observer) => {
  const button = document.getElementById('button');

  if (button) {
    button.addEventListener('click', () => console.log('clicked!'));

		// No need to observe anymore. Clean up!
    observer.disconnect();
  }
});

```

## So, how responsive is it?

I mentioned earlier how polling can introduce a small amount of deadweight time when responding to a DOM change. Much of this risk hinges on the size of the interval you’re using, but it doesn’t help that both `setTimeout()` and `setInterval()` run their callbacks on the primary task queue, which means they’ll always run on a _future_ iteration of the event loop.

A MutationObserver, however, fires its callback on the [microtask queue](https://dom.spec.whatwg.org/#queue-a-mutation-observer-compound-microtask), which means it doesn’t need to wait around for a full rotation of the event loop before firing its callback. It’s far more responsive.

I did a rudimentary experiment with this using `performance.now()` in the browser to see how long it took for the click event listener to be added to the button after it was mounted to the DOM. Remember, this was with a no delay being set in our `setTimeout()`, so the delay we see is likely the pace of the event loop turning itself (plus. Here are the results:

| **Approach** | **Delay to Add Listener** |
| --- | --- |
| polling | ~8ms |
| MutationObserver() | ~.09ms |

That’s a pretty staggering difference. Using polling with a zero-delay `setTimeout()` to attach a listener was around around 88 times slower than the MutationObserver. Not bad.

## Stop Polling, Start Observing

Considering the performance benefits, the simpler API, and the ubiquitous browser support, it’s hard to bet on DOM polling when matched against a MutationObserver. I’m hoping you find it useful when working with eventually-mounted nodes in your own projects. I’ll be looking for other scenarios when it might come in handy myself.