Skip to content

MutationObserver handling costs ~2.5× on pages that re-render large DOM subtrees (Firefox) #3110

Description

@daaain

Have you searched for an existing issue?

  • Yes, I tried searching and reviewed the pinned issues

Brief Summary

Hey, I've been periodically experiencing page slowdowns with this extension during normal use in Firefox, but now I got a chance to narrow it down with a really bad case. The fixes below are quite simple and should fix reported cases like Reddit without site specific hacks.

I'm happy to open a PR for any or all of these if you want, or please take the diffs or just the ideas, whatever works for you!

Full disclosure: the details below are from Claude Code which I used to create the reproduction and the profiling.

Summary

On pages that repeatedly replace large chunks of DOM, the content script's MutationObserver roughly doubles or triples the time each update takes in Firefox. With enough DOM, the page visibly tears and stalls for seconds. I found this on my tokenizer web app (daaain/online-llm-tokenizer). It re-renders a few thousand small elements per card after each keystroke, and pasting ~4,000 tokens froze the page for several seconds while the extension was enabled.

I profiled it by instrumenting a copy of the extension. The cost is general rather than site-specific, and comes from three places:

  1. getShadowDOM() calls browser.dom.openOrClosedShadowRoot() for almost every node. In Firefox, elem.openOrClosedShadowRoot exists but is null when there's no shadow root, so the ternary falls through to the WebExtension API.
  2. Every added and removed node is handled on its own (handleObserverAdd / handleObserverRemove → getInputs → querySelectorAll, ignoredNode, getShadowDOM…). Replacing the children of one container with 2,000 elements means about 4,000 separate handler calls.
  3. getInputs() calls kpxcFields.isVisible() before checking the input type. isVisible() starts with getBoundingClientRect(), which forces a synchronous reflow. So an ordinary checkbox inside re-rendered content costs a full-page reflow and is then thrown away because checkbox isn't in inputTypes. On the tokenizer page each of those reflows took 1.8–2.4 s in Firefox.

A prototype that fixes all three brings the overhead from about +145% to about +5% on the reproduction below, and still detects a login form that is added to the page later.

This also looks like the general cause behind site-specific entries like the Reddit ones in #2998. Handling each batch of changes once would make most of those ignore lists unnecessary.

Environment

  • Firefox 156.0.1, Linux (Xvfb, software rendering, so absolute times are higher than on a normal desktop; the ratios are what matter)
  • KeePassXC-Browser develop @ 85a0f0d (1.10.4), built with dist/manifest_firefox.json, installed as a temporary add-on
  • No KeePassXC application connected. The observer is installed anyway, since useObserver defaults to true.

Reproduction

Save this as an HTML file, serve it over HTTP, and click Run 10 rounds with the extension enabled and then disabled. It re-renders six panels one after another, each in its own task, like replies arriving from six web workers.

<!doctype html>
<meta charset="utf-8">
<title>KeePassXC-Browser observer cost repro</title>
<style>
  ruby { margin-right: 3px; }
  ruby > span { background: #eee; padding: 2px; }
  .panel { border: 1px solid #ccc; margin: 8px 0; padding: 8px; }
</style>
<p>
  <button id="run">Run 10 rounds</button>
  <label><input id="withCheckbox" type="checkbox" checked> Re-rendered panels include a checkbox</label>
</p>
<pre id="result"></pre>
<div id="panels"></div>
<script>
  const PANELS = 6
  const TOKENS = 2000
  const panels = document.getElementById('panels')
  panels.innerHTML = '<div class="panel"></div>'.repeat(PANELS)
  const render = (n, withCheckbox) =>
    Array.from({ length: TOKENS }, (_, i) => `<ruby><span>tok${(i * n) % 997}</span><rt>${i}</rt></ruby>`).join('') +
    (withCheckbox ? '<label><input type="checkbox"> An option</label>' : '')
  const nextTask = () => new Promise((r) => setTimeout(r, 0))
  const nextFrame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)))
  async function run() {
    const withCheckbox = document.getElementById('withCheckbox').checked
    const times = []
    for (let n = 1; n <= 10; n++) {
      await nextFrame()
      const t = performance.now()
      for (const panel of panels.children) {
        panel.innerHTML = render(n, withCheckbox)
        await nextTask()
      }
      await nextFrame()
      times.push(Math.round(performance.now() - t))
    }
    times.sort((a, b) => a - b)
    document.getElementById('result').textContent =
      `checkbox: ${withCheckbox}, median ${times[5]} ms per round (${times.join(', ')})`
  }
  document.getElementById('run').addEventListener('click', run)
</script>

Measurements

Median time per round (six panels re-rendered, then the next frame), Firefox 156. Each build was run without instrumentation:

Extension build Panels without a checkbox Panels with a checkbox
No extension 229 ms 255 ms
1.10.4 (stock) 561 ms (+145%) 667 ms (+162%)
+ fix 1 (shadow root) 474 ms 546 ms
+ fixes 1 and 2 (type before visibility) 515 ms 537 ms
+ fixes 1, 2 and 3 (batch-level handling, prototype) 263 ms (+15%) 265 ms (+4%)

The rows for fixes 1 and 2 are within about ±40 ms of noise of each other. Fix 3 is what removes most of the cost.

Where the time goes

Time inside the observer callback on the page without a checkbox, over 10 rounds, from a build with performance.now() wrappers around the functions. The wrappers add their own overhead, so read these as proportions:

Function Calls Time
observer callback (total) 62 9,981 ms
handleObserverAdd 120,001 5,271 ms
handleObserverRemove 108,000 4,037 ms
getInputs 228,000 6,364 ms
↳ getShadowDOM(target) 228,000 2,833 ms
↳ querySelectorAll('input') 228,000 1,061 ms
ignoredNode 684,068 2,116 ms

With the checkbox, isVisible() adds 60 calls taking 1,752 ms, of which getBoundingClientRect() is 1,679 ms. isTopElement() / elementFromPoint() are negligible because the reflow has already happened by then.

On the real tokenizer page (six cards, ~4,800 tokens each), isVisible() ran 38 times in 4 updates and took 68.9 s in total. That was 99.9% of the observer's time, spent forcing reflows of the page for a checkbox the extension then ignored.

Suggested fixes

1. Don't call the WebExtension API when the property says there's no shadow root

content/observer-helper.js, getShadowDOM():

     try {
-        return elem.openOrClosedShadowRoot ? elem.openOrClosedShadowRoot : browser.dom.openOrClosedShadowRoot(elem);
+        // Firefox exposes the property, and it's null when there's no shadow root: don't fall back to the API then
+        return 'openOrClosedShadowRoot' in elem ? elem.openOrClosedShadowRoot : browser.dom.openOrClosedShadowRoot(elem);
     } catch (_e) {

2. Check the input type before visibility

content/observer-helper.js, end of getInputs(). Both checks are filters with no side effects, so the result is the same, but non-text inputs no longer force a reflow:

     for (const field of inputFields) {
+        // Check the type first: isVisible() forces a synchronous layout
+        const type = field.getLowerCaseAttribute('type');
+        if (!kpxcObserverHelper.inputTypes.includes(type)) {
+            continue;
+        }
+
         if ((!ignoreVisibility && !kpxcFields.isVisible(field))
             || kpxcFields.isSearchField(field)) {
             continue;
         }

-        const type = field.getLowerCaseAttribute('type');
-        if (kpxcObserverHelper.inputTypes.includes(type)) {
-            inputs.push(field);
-        }
+        inputs.push(field);
     }

3. Handle each batch of changes, not each node (prototype, needs your judgement)

Instead of calling handleObserverAdd on every added node, collect each record's target into a Set and scan each parent once with querySelectorAll. getInputs() already filters out inputs that were identified before. For removals, instead of scanning every removed subtree, check whether any known input has left the document:

+        const addedTargets = new Set();
+        let anyRemoved = false;
         for (const mut of mutations) {
             ...
             if (mut.type === 'childList') {
-                mut.addedNodes.forEach(function (node) {
-                    kpxcObserverHelper.handleObserverAdd(node);
-                });
-                mut.removedNodes.forEach(function (node) {
-                    kpxcObserverHelper.handleObserverRemove(node);
-                });
+                if (mut.addedNodes.length > 0) {
+                    addedTargets.add(mut.target);
+                }
+                if (mut.removedNodes.length > 0) {
+                    anyRemoved = true;
+                }
             } else if ...
         }
+
+        for (const target of addedTargets) {
+            kpxcObserverHelper.handleObserverAdd(target);
+        }
+        if (anyRemoved && kpxc.inputs.some(input => !input.isConnected)) {
+            kpxcIcons.deleteAllHiddenIcons();
+        }

With this prototype, a login form injected 1.5 s after load still gets its icon, the same as with the stock build. I haven't checked the edge cases you'll know about better than I do: shadow-DOM-heavy sites, ignoredNode targets such as a mutation whose target is <html>, and the YouTube/Reddit special cases. MAX_MUTATIONS currently caps records but not addedNodes, which this change also deals with.

Further idea: keep layout reads out of the MutationObserver callback

Even with the fixes above, any real text input inside re-rendered content triggers isVisible() synchronously inside the callback. That forces a reflow at a point where the browser can't batch it with its normal rendering. Deferring visibility checks to requestAnimationFrame (or using an IntersectionObserver) would let them reuse the layout the browser does anyway.

Expected Versus Actual Behavior

I really like this extension, but it should tread much more carefully to prevent a lot of extra work for browsers. I'd much rather occasionally have to press Redetect fields manually than have a constant, heavy load added to rendering.

Steps to Reproduce

See full repro above, but happens in the wild too.

KeePassXC-Browser Debug Information


Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions