Skip to content

Performance issue: MutationObserver reprocesses all stylesheets on every DOM change #188

Description

@ForestSpark

Environment

  • uniwind version: 1.0.4
  • Platform: Web (React + Expo Router)
  • Framework: React 19, React Native Web

Description

We're experiencing severe performance degradation (7+ second blocking times) when using uniwind alongside Emotion CSS-in-JS. The root cause is that cssListener.js's MutationObserver reprocesses all stylesheets from scratch every time a new stylesheet is added to <head>.

When Emotion runs in dev mode, it injects individual <style data-emotion> tags for each CSS rule. In our case, this results in 2000+ style tags, each triggering a full reprocessing cycle.

Root Cause

In cssListener.js, the MutationObserver calls initialize() on every childList mutation:

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === "childList") {
      this.initialize(); // ⚠️ Reprocesses ALL stylesheets
    }
  }
});

The initialize() method iterates through all document.styleSheets without tracking which ones have already been processed, creating O(n²) behavior:

initialize() {
  for (const sheet of Array.from(document.styleSheets)) {
    // No check if already processed
    this.addMediaQueriesDeep(rules);
  }
}

Performance Profile

From Chrome DevTools profiling a single navigation:

  • addMediaQueriesDeep: 3,600ms (51.3%)
  • initialize: 751ms (10.7%)
  • addMediaQuery: 337ms (4.8%)
  • collectParentMediaQueries: 301ms (4.3%)

Total blocking time from cssListener: ~4.5 seconds (65% of 7s navigation)

Suggested Solutions

1. Debounce MutationObserver (low-hanging fruit)

Batch multiple rapid mutations into a single initialize() call:

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === "childList") {
      if (this.initializeTimeout) clearTimeout(this.initializeTimeout);
      this.initializeTimeout = setTimeout(() => {
        this.initialize();
        this.initializeTimeout = null;
      }, 50);
    }
  }
});

2. Track processed stylesheets (more impactful)

Skip stylesheets that have already been processed:

processedStyleSheets = new WeakSet();

initialize() {
  for (const sheet of Array.from(document.styleSheets)) {
    if (this.processedStyleSheets.has(sheet)) continue;
    
    // ... process stylesheet ...
    
    this.processedStyleSheets.add(sheet);
  }
}

3. Ignore Emotion style tags (alternative)

If uniwind doesn't need to track [data-emotion] styles, they could be filtered:

initialize() {
  for (const sheet of Array.from(document.styleSheets)) {
    if (sheet.ownerNode?.hasAttribute('data-emotion')) continue;
    // ... process stylesheet ...
  }
}

Workaround

We've temporarily patched our local node_modules/uniwind with solutions #1 and #2, which reduced navigation time from 7s to <1s. We've also enabled Emotion's speedy mode to eliminate the style tag proliferation.

Impact

This affects any uniwind + CSS-in-JS (Emotion, styled-components in dev mode) setup where many style tags are injected dynamically. It's particularly severe in micro-frontend architectures where multiple apps coexist.

Would you be open to a PR implementing one or more of these optimizations?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions