Ist die Performance eines MutationObserver mit 100.000 Callbacks besser als die Performance von 100.000 MutationObservers mit je einem Callback? Und wenn ja, wie groß ist der Unterschied? Diese Frage tat sich vor mir auf, als ich einst an einer Browser-Extension schraubte, die das DOM einer Seite überwacht und in diesem Kontext sind sowohl Performance als auch MutationObserver wichtig. Also habe ich ein paar Experimente gestartet, die nicht nur eine Antwort auf die Ausgangsfrage, sondern auch einige Erkenntnisse zu den Unterschieden zwischen den diversen Browsern zutage geführt haben. Und weil der ganze Prozess ein ziemlich gutes Beispiel für zielgerichtete Performance-Forschung im Entwickler-Alltag ist, will ich meinen Weg zum Ergebnis in diesem Post nachzeichnen. Falls das uninteressant ist, geht's hier direkt zum Fazit.

MutationObserver

Der MutationObserver ist im modernen Browser das Mittel der Wahl um über Änderungen am DOM im Bilde zu bleiben. Die API ist überschaubar, aber gewöhnungsbedürftig:

// Ziel der Überwachung
const targetNode = document.querySelector(".foo");

// Optionen der Überwachung
const config = { attributes: true, childList: true };

// Handler-Funkion
const callback = function(mutations) {
  for(const mutation of mutations) {
    if (mutation.type === "childList") {
      console.log("Inhalt geändert");
    } else if (mutation.type === "attributes") {
      console.log("Attribut " + mutation.attributeName + " geändert");
    }
  }
};

// Setup
const observer = new MutationObserver(callback);

// Start
observer.observe(targetNode, config);

// Ende via observer.disconnect()

Die etwas komplizierte Konstruktion von MutationObserver wurde aus Performance- und Stabilitätsproblemen mit Mutation Events geboren. Die Mutation Records (die Parameter der Handler-Funktion) trudeln asynchron ein und werden gebündelt, so dass auch ein größeres innerHTML, das viele DOM-Nodes durch andere Nodes ersetzt, nur einen einzigen Funktionsaufruf verursacht. In den Überwachungs-Optionen lässt sich festlegen, ob nur das Ziel-Element überwacht wird, oder ob auch dessen gesamter Sub-Tree betrachtet werden soll und welche Änderungen (Attribute, Text, Kind-Elemente) mitgeschnitten werden sollen.

Alles schön und gut, aber wie sehen die Performance-Charakteristika von MutationObservers aus?

Gibt es überhaupt ein Performance-Problem mit MutationObserver?

Mit modernem Browser-Tooling können wir bei der JavaScript-Performance-Analyse extrem tief ins Detail gehen, doch der erste Schuss sollte eigentlich immer dem Prinzip „stumpf ist Trumpf“ folgen. Wir bilden eine möglichst einfache Hypothese, die, wenn zutreffend, alle weiteren Untersuchungen überflüssig macht. Und dann versuchen wir, diese Hypothese in einem möglichst einfachen Experiment zu widerlegen. Sollte das gelingen, müssen wir weitere Untersuchungen anzustellen und/oder die Hypothese anpassen, während wir, wenn die Hypothese auf die erste Beobachtung passt, nichts weiter zu untersuchen haben.

Für diese erste Messreihe genügt der folgende, extrem billige Versuchsaufbau:

// Gewagte Hypothese: 100.000 MutationObserver sind kein Problem

let result = 0;
let target = document.querySelector("div");

for (let i = 0; i < 100000; i++) {
  new MutationObserver(function handler() {
    result++;
  }).observe(target, { attributes: true });
}

document.querySelector("button").onclick = () => {
  target.setAttribute("data-test", "1")
  setTimeout(() => console.log(result), 3000);
};

Einfacher kann's nicht werden: das Programm kommt als Inline-Script in ein HTML-Dokument mit einem <div> und einem <button> und fertig! Die Handler-Funktion zählt eine später ausgegebene Variable hoch, damit die JavaScript-Engines nicht auf die Idee kommen, den gesamten Code (der ohne diese Variable keine wahrnehmbare Auswirkung hätte) einfach wegzuoptimieren. Als Nächstes sollten wir den Versuchsaufbau in einem privaten Tab öffnen (kein Cache, keine Browser-Extensions) und mit den Devtools messen, ob sich irgendwelche Performance-Auffälligkeiten einstellen, doch in diesem Fall ist bereits beim Laden der Seite, noch vor dem Nachmessen, klar, dass es keine gute Idee sein kann, 100.000 MutationObserver zu betreiben:

Anscheinend braucht mein relativ neuer Desktop-PC mit seinen über 9000 CPU-Kernen mehr als 4 Sekunden um 100.000 einfachstmögliche MutationObserver in einer einfachstmöglichen Webseite einfach nur zu starten ‐ und hier wird nur das oben gezeigte Inline-Script evaluiert, zu einer DOM-Mutation kam es noch gar nicht! Die Test-Webseite von der lokalen Festplatte zu laden dauerte bereits spürbar lang, da das Inline-Script den HTML-Parser blockierte. Damit ist auf jeden Fall schon mal geklärt, dass MutationObserver für Performance nicht per se egal sind. Wir können außerdem erkennen, dass der Löwenanteil der vergangenen Zeit (self time) auf das Starten der Observation, d.h. den Aufruf von observe() entfällt.

Triggern wir per Button-Klick eine DOM-Mutation, so sehen wir einen mit 0,5 Sekunden recht stattlichen Trödel-Block in den Microtasks, worin sich das eigentliche MutationObserver-Geschehen abspielt:

Das ist nicht so absurd viel (denn es sind immerhin 100.000 MutationObserver), aber es ist auch nicht nichts. Unsere Ausgangshypothese, nach der 100.000 MutationObserver keinen wirklichen Einfluss auf die JS-Performance haben, ist damit dahin und wir dürfen weiterforschen.

Als Nächstes ist natürlich die Gegenprobe mit 100.000 Funktionen und einem einzigen Observer angesagt, die in einem ähnlich simplen Versuchtsaufbau stattfindet:

// Hypothese: 100.000 Funktionen sind genau so lahm wie 100.000 Observer

let result = 0;
let target = document.querySelector("div");

let handlers = [];

for (let i = 0; i < 100000; i++) {
  handlers.push(function handler() {
    result++;
  });
}

new MutationObserver(function rootHandler() {
  for (const handler of handlers) {
    handler();
  }
}).observe(target, { attributes: true });

document.querySelector("button").onclick = () => {
  target.setAttribute("data-test", "1");
  setTimeout(() => console.log(result), 3000);
};

Non-Arrow-Functions sind bei Performance-Tests immer sehr praktisch, da wir ihnen problemlos Namen verpassen können, die ihrerseits das Profiling-Ergebnis besser lesbar machen. Das Programm bauen wir wieder als Inline-Script in ein einfaches HTML-Dokument ein, laden im privaten Tab und messen nach:

Diese Variante ist so schnell, dass die Messung der Laufzeiten des Mutations-Microtasks gerade so erfasst wurde, während der einmalige Aufruf von observe() im Profiling noch nicht mal auftaucht. Damit scheint die Ausgangsfrage beantwortet: Es gibt einen nicht nur mess-, sondern sogar spürbaren Unterschied zwischen einem Observer mit N Callbacks und N Observern mit einem Callback. Zeit für den Feierabend?

Generelles Phänomen oder Browser-Spezialität?

Die bisherigen Versuche haben alle nur in Chrome stattgefunden, doch es gibt (Stand Mitte 2021) noch ein paar weitere Browser, die nicht Chromium-basiert sind. Es lohnt sich, die Versuche auch dort zu reproduzieren, um auszuschließen, dass wir es mit Chrome-Bugs zu tun haben. Öffnen wir also den 100.000-Observer-Versuch in Firefox und messen mal nach, ob dort auch mehrere Sekunden für observe() ins Land gehen:

Dieser hier 161 Millisekunden dauernde „Funktionsaufruf“ ist kein Funktionsaufruf im eigentlichen Sinne, sondern die Gesamtheit des Inline-Scripts (quasi dessen main()-Funktion). Wo also Chrome allein 4 Sekunden benötigt, um 100.000-mal observe() zu starten, macht Firefox den gleichen Job plus alles andere (z.B. 100.000-mal new MutationObserver()) in einem Bruchteil der Zeit. Das ist schon ein ausgesprochen bemerkenswerter Unterschied – kann es sein, dass in Chrome ein Bug schlummert, der observe() besonders langsam macht? Die Antwort wird offenbar, wenn wir im Firefox eine erste DOM-Mutation auslösen:

Oh, 21 Sekunden für das Click-Event, das die DOM-Änderung verursacht!? Sportlich! Es sieht aus, als würden Chrome und Firefox einen Teil der Arbeit, die bei oder zwischen observe() und der ersten DOM-Mutation anfällt, zu unterschiedlichen Zeitpunkten ausführen. Offenbar wird einiges an Arbeit, das in Chrome beim Aufruf von observe() stattfindet, im Firefox erst dann ausgeführt, wenn tatsächlich ein zu observierendes Ereignis stattfindet. Um welche Arbeit es sich dabei genau handelt, vermag ich den Spezifikationen nicht zu entnehmen, was nahelegt, dass es sich um ein reines Implementierungsdetail handelt. Beide Browser setzen die Standards korrekt um, nehmen aber unterschiedliche Wege zum Ziel (was durchaus nicht ungewöhnlich ist). Firefox versucht, den Start des Observers zu optimieren, indem Arbeit aufgeschoben wird, während Chrome probiert, nach einmaligen (vergleichsweise langsamen) Observer-Setup den ersten Callback-Aufruf möglichst flott zu gestalten.

Festzuhalten bleibt: einen MutationObserver zu starten ist keine Kleinigkeit, auch wenn sich der Effekt in verschiedenen Browsern unterschiedlich manifestiert.

Wie groß ist das Problem wirklich?

Die bisherigen Versuchsaufbauten haben erst mal nur gezeigt, dass MutatationObserver für JavaScript-Performance relevant sind, wenn sie zu tausenden gestartet werden. Was ist aber, wenn es nur hunderte oder gar noch weniger Observer gibt? Eine ermüdende Messreihe (durchgeführt mit Chrome auf einem neuen Desktop-Ryzen ohne Drosselung o.Ä.) führt zu folgendem Bild:

Alles unterhalb von 1000 Observern ist nicht messbar. Darüber steigt die Laufzeit für observe() an, wobei es erst ab 10.000 wirklich kritisch wird. Das wären nicht unmöglich viele Observer, aber auf jeden Fall extrem viele. Unterhalb dieser Extrem-Grenze gibt es also kein wirkliches Problem damit, viele MutationObserver zu betreiben.

Was im Übrigen keine signifikante Rolle zu spielen scheint, ist die Komplexität der observierten DOM-Struktur. Egal wie viele Elemente sich innerhalb oder im Umfeld des Observations-Zieles befinden, der Effekt ist nahezu nicht messbar.

Fazit

Die Ausgangsfrage „ist die Performance eines MutationObserver mit 100 Callbacks besser als die Performance von 100 MutationObservers mit je einem Callback?“ lässt sich also wie folgt beantworten:

  1. Ein MutationObserver, dessen Callback viele Funktionen aufruft, performt grundsätzlich besser als viele MutationObservers mit je einem Callback.
  2. Ein signifikanter Unterschied tritt nicht bei Mutation Events an sich auf, sondern beim Starten der Observation. Im Firefox scheint das „starten der Observation“ beim ersten Mutation Event stattzufinden und nicht, wie in Chrome, beim Aufruf von observe(). Ab dem ersten Aufruf zeigen beide Browser das gleiche Verhalten.
  3. Insgesamt ist die Performance des MutationObserver auf modernen Geräten so gut, dass sich die Unterschiede bei weniger als 1000 Observern nicht wirklich zu Buche schlagen.

Als Best Practices für MutationObservers können wir festhalten:

  1. Es ist kein Problem, 10 oder 20 MutationObserver auf einmal zu betreiben
  2. Falls es sich für den aktuellen Use Case anbietet, könnte das Event-Delegation-Pattern auf MutationObserver angewendet werden: einfach den Document Root observieren, subtree auf true stellen und im Callback je nach betroffenem Element verfahren.
  3. Falls jede Millisekunde zählt, aber DOM-Updates nicht ab Laden der Seite zu erwarten sind, könnte der Aufruf von observe() aus dem Critical Path genommen werden, z.B. via requestIdleCallback(). Firefox macht das quasi von sich aus (schiebt die Arbeit zum ersten Mutation Event), aber mit requestIdleCallback() oder anderen Aufschieb-Mechanismen können wir eine vergleichbare Optimerung auch in anderen Browsern umsetzen.

Im Klartext: modernes JavaScript/DOM ist so schnell, dass MutationObserver per se kein Performance-Problem darstellen, selbst wenn wir hunderte von ihnen auf einmal betreiben. Zwar sind Einzel-Observer messbar schneller, aber der Unterschied ist zu vernachlässigen, solange wir keine vollends absurden Mengen von Observers zu Felde führen. Würde ich nach Möglichkeiten suchen, mein Web-Projekt schneller zu machen, würde ich mich zuerst um andere Baustellen kümmern.