Back to All Posts

Use a MutationObserver to Handle DOM Nodes that Don’t Exist Yet

Exploring how the MutationObserver API stacks up against the more traditional approach of polling for nodes that’ll eventually be created.

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 has been around for a bit now, and is extremely well-supported in modern browsers. 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 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, 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.


Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

1 comments