Explore more topics
Developer guides

Should you still use Material UI? What its sx styling cost my React app

Is Material UI slow? I moved my busiest React pages from MUI's sx styling to CSS modules: long frames fell 33% on the dashboard and 40% on the watchlist.

As of 2026-10-22

Dashboard
-33%2,449 ms to 1,652 ms of long frames
Watchlist
-40%2,574 ms to 1,537 ms
First-load JS
+19 KBgzipped, about 1%
Visual change
Under 0.65%of pixels on every compared page
Jump to a section

TL;DR

Material UI can be slow on pages that re-render often. MUI styles components with Emotion, which turns style objects into CSS in the browser on every render, and that work adds up when a page re-renders many times.

I moved NexusTrade's most-viewed pages and its shared header to static CSS modules. Measured on production builds at 4x CPU, load-time long frames fell 33% on the dashboard, 40% on the watchlist and 36% on Live Trading. A page that renders once barely changed.

My answer to whether you should still use Material UI is no. Coding models now write and maintain plain CSS as quickly as MUI's ready-made components ever saved you, so use MUI only when implementation velocity matters more than page velocity.

Results by page

Change in load-time long frames, production build at 4x CPU

Scale: −40 to +40 % · zero baseline

Phase 2: shell and pages

  • Dashboard-33%
  • Watchlist-40%
  • Watchlist, a list open-35%
  • Live Trading-36%
  • Aurora chat (shell only)-25%

Phase 1: page bodies

  • Portfolio dashboard-22%
  • Aurora chat-16%
  • Profile-10%
  • Learn course+1%

Median of three page loads per build. Negative is faster.

Show the table · 9 rows, 5 columns

Scroll sideways to compare all columns. Select a heading to sort.

Dashboard2,4491,6521,577707
Watchlist2,5741,5371,520683
Watchlist, a list open2,8861,8701,591713
Live Trading2,2531,43651154
Aurora chat (shell only)1,9121,436887698
Portfolio dashboard2,2771,7811,530814
Aurora chat2,1891,8431,258763
Profile1,3941,248288134
Learn course945957315295

Medians of three loads, in milliseconds. Long frames are animation frames of 50 ms or more; data-load frames are the long frames started by an API response.

Phase 1 migrated the bodies of the chat, portfolio dashboard, profile and Learn pages. Phase 2 migrated the shared header, footer and page template, then the dashboard, watchlist and Live Trading pages.

The chat page shows how much the shell mattered: its body had already moved in phase 1, and moving only the header cut another 25%.

Should you still use Material UI?

No. For new work, write plain CSS instead.

MUI's value was ready-made components that let you build quickly. Coding models such as Claude Opus 5.5 and Sol now write that CSS just as quickly, express designs more freely than a component library allows, and maintain it as the design changes. That leaves MUI's runtime cost with nothing to pay for it.

Use MUI only when implementation velocity matters more than page velocity, such as a quick prototype or an internal tool.

  1. The runtime cost is real

    Moving sx to CSS modules cut long-frame time 33% on the NexusTrade dashboard and 40% on the watchlist.

  2. The visual result does not depend on MUI

    Every page I migrated renders within 0.65% of its original pixels, so users see the same product.

Where the time went

Each time a component with an sx prop renders, Emotion reads the theme, merges the component's default, theme and variant styles with the sx object, and resolves shortcuts such as p: 2 into pixels. It then serializes the result into a CSS string, hashes it, checks a cache, and inserts a new rule when the hash is new. Inserting a rule makes the browser recalculate styles for the page.

That runs for every styled element on every render, even when nothing visual changed. Each MUI element is also several React components deep, so React has more of the tree to walk.

A CSS module replaces all of that with a class name fixed at build time. The browser parses the CSS once, when the file loads, and a re-render just sets the class on a plain element.

  1. Pages that re-render often gain the most

    The NexusTrade dashboard re-renders as each API response arrives (the app runs React 17, which does not batch those updates), so the styling work repeated many times per load.

  2. Pages that render once gain little

    The Learn course page went from 945 ms to 957 ms, a change inside the run-to-run noise.

The shared shell was the hidden cost

After phase 1, the migrated pages still spent 7 to 9% of their JavaScript time inside Emotion, about the same as pages I had not touched. Counting Emotion-styled elements by page region showed why.

Two header menus, the profile menu and the mobile menu, were mounted on every page and hidden. MUI's keepMounted prop kept about 190 styled elements in the page, invisible, on desktop and phone alike. Removing that prop was a two-line change.

Show the table · 5 rows, 6 columns

Scroll sideways to compare all columns. Select a heading to sort.

Aurora chat (migrated)108021192312
Portfolio dashboard (migrated)1018075192461
Dashboard34180139192765
Live Trading24710275192638
Watchlist382125159192880

Emotion-styled elements on the page after phase 1, by region, at 1440 px.

Pick pages by traffic and re-render count

I picked the phase 2 pages from 30 days of signed-in page views. The dashboard alone was 36% of views. Backtest and strategy pages carried the most sx props in the codebase but did not make the top 25 pages by views, so I left them.

I migrated the pages with the most traffic first, starting with the shared header that every page renders.

The download got bigger

The styles moved from JavaScript objects into CSS files, which the browser handles far more cheaply but still has to download. JavaScript barely shrank because NexusTrade still ships MUI and Emotion for the menus, dialogs and form fields it has not replaced yet. The bundle shrinks when the last MUI import goes.

Short hashed class names in production builds saved about 6 KB, because each class name appears in the CSS and again in the JavaScript class map.

Phase 1 first load
1,815 to 1,918 KB+5.7% gzipped
Phase 2 first load
1,918 to 1,937 KB+19 KB gzipped

Proving the pages still look the same

The NexusTrade dashboard at 1440 px, the MUI version on the left and the CSS-module version on the right, visually identical.
Dashboard at 1440 px: MUI on the left, CSS modules on the right.
An open watchlist at 390 px phone width, the MUI version on the left and the CSS-module version on the right; only icon glyphs and live prices differ.
Watchlist at 390 px: MUI on the left, CSS modules on the right.

Every page was captured at 1440 px and 390 px against the same data, once on the original build and once on the migrated build, and compared pixel by pixel. I accepted a page only when its height matched exactly and fewer than 1% of pixels differed. Most of what remains is icon glyphs, because I replaced MUI's filled icons with Lucide's outlined set.

The comparison caught every layout slip below, usually as a page that came out a few pixels shorter.

MUI behaviors I had to reproduce

  1. Compound selectors in a shared stylesheet beat component classes

    A shared rule such as .chip.small outranks a component's own single class. I wrapped every modifier in :where() so each shared selector has the specificity of one class.

  2. Container's gutter outranks sx padding

    MUI emits Container's 24 px side padding at 600 px and wider as a media rule after the sx rule, so an sx px: 0 never applied there. The migrated composer had to keep the gutter MUI actually rendered.

  3. ListItemText margins change with a second line

    The margins are 4 px top and bottom, and 6 px when a secondary line is present. Copying the 4 px made every two-line chat session row shorter.

  4. Small IconButton is sized by padding

    It is 5 px of padding around the icon, so a 24 px icon makes a 34 px button. A fixed 32 px box shortened each automation row by 2 px.

  5. Disabled styling beat sx colors

    MUI's .Mui-disabled rule outranked colors set through sx. Moving a color to an inline style made disabled buttons look enabled, so runtime colors apply only while a button is enabled.

  6. Typography always sets a weight

    Text inside a semibold button stayed regular in MUI, so the replacement Text component sets the regular weight instead of inheriting it.

  7. React escapes quotes inside a server-rendered style tag

    A layout test that rendered its CSS as <style>{css}</style> turned grid-template-areas quotes into &quot;, which the browser drops. Raw HTML injection fixed the fixture.

Keeping shared styles overridable

Show the CSS
/* Every selector has the specificity of one class, so a component's own
   class always overrides the shared primitive, the way sx did. */
.button { display: inline-flex; min-width: 64px; padding: var(--nt-button-padding-y) var(--nt-button-padding-x); }
.contained { background: var(--nt-action); color: var(--nt-action-ink); }
.contained:where(:hover:not(:disabled)) { background: var(--nt-action-hover); }
.button:where(:hover:not(:disabled)) { box-shadow: var(--nt-surface-shadow); }

/* A runtime color that still yields to the disabled look. */
.enabledColor:where(:enabled) { color: var(--enabled-color); }

/* Disabled comes last so it overrides every variant and tone. */
.button:where(:disabled) { color: rgb(var(--nt-ink-channel) / 0.26); }

How I measured

Show the frame observer

I built both versions with the production Vite build and served them against the same local database. For each page, a headless Chrome session throttled the CPU 4x, loaded the page, waited 6 seconds for data to settle, and summed every long animation frame of 50 ms or more. I report the median of three loads.

Frames triggered by data loads are long frames whose scripts were invoked from an XMLHttpRequest callback, which is where the dashboard's repeated re-renders happen.

JavaScript
new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 50) continue;
    const fromXhr = entry.scripts.some(s => /XMLHttpRequest|onloadend|onreadystatechange/i.test(s.invoker || ""));
    frames.push({ duration: entry.duration, blocking: entry.blockingDuration, fromXhr });
  }
}).observe({ type: "long-animation-frame", buffered: true });

Continue exploring

Discussion

Sign in or create a free account to join the discussion.

No comments yet.