Every MUI performance guide opens with the same advice: stop using barrel imports, because they bloat your bundle and slow your dev server. It is repeated confidently and almost never with a number attached.

So we measured it. Twelve MUI components and six icons, the same component tree rendered three ways, only the import style varying. The bundle claim turned out to be false — the two builds are byte-identical. The dev-server claim is real but points at the wrong metric, and almost all of the cost comes from one thing the advice treats as a footnote.

What we measured

The fixture is a Vite 7 + React app generated by a script, so every variant starts from identical code:

  • 12 MUI components (Button, TextField, Card, Typography, Box, Stack, Chip, Divider, Paper, Alert, Switch, CardContent) and 6 icons — roughly the shape of an admin screen
  • Three import styles: barrel for everything, path imports for everything, and a mixed variant that reverts only the icons to a barrel, to isolate which of the two barrels actually costs anything
  • Test machine: Apple M5 (10 cores), Node 24, MUI 9.3.1, Vite 7. Your absolute numbers will differ; the ratios are the finding.
  • Version caveat: everything here is MUI 9 on Vite 7. Tree-shaking behaviour and package layout have changed across MUI majors, so if you are on v5 or v6 — still common — treat the bundle-size result as a hypothesis to check on your own build rather than an established fact. The 30-second gzip comparison below is how you check it.

Per variant we recorded cold dev-server start, the first-load module-graph crawl with transferred bytes, production build time (3 runs each, min–max reported), and the built bundle raw and gzipped. A second harness renders the same list via sx, styled(), and a plain CSS class in a production build under real time in headless Chrome, median of 5 commits.

The fixture generator, both benchmark scripts, the raw run logs, and the results JSON are in the evidence folderbench-results.json and styling-results.json — so you can check the arithmetic or rerun it.

What we found

barrel importspath imports
Production bundle362.3KB (115.1KB gzip)362.3KB (115.0KB gzip)
Dev cold start98 ms 96–32680 ms 65–81
Dev first load764 ms 733–766 · 9,775KB147 ms 142–147 · 2,161KB
Production build1,947 ms 1894–1984563 ms 563–606

Three findings, two of which contradict the usual advice.

Path imports do not shrink your production bundle. The builds differ by 0.1KB gzipped — noise. Rollup tree-shakes MUI; the barrel is erased at build time. If you converted a codebase to path imports so users would download less JavaScript, they download exactly the same JavaScript.

"Faster dev startup" is the wrong metric. Cold start differs by 18 ms, and the barrel variant's range even overlaps the path variant's. First load differs by 617 ms — 764 ms against 147 ms, a 5.2x gap — because the dependency is pre-bundled and then served: about 9.8MB of source instead of about 2.2MB. One of those two numbers moves when you fix your imports, and it is not the one your terminal prints on startup.

Builds run 3.5x slower on barrel imports — 1,947 ms against 563 ms. Note the absolute before you act on the ratio: at this fixture's size that is 1.4 seconds, which matters on a monorepo running hundreds of builds a day and is irrelevant on a small app. The multiple is real; whether it is worth anything depends entirely on your build count.

Before anything else: check for duplicate copies

Thirty seconds, and it invalidates everything below if it fires:

npm ls @mui/material @emotion/react

More than one version of either in the tree means two runtimes, two style caches, and tree-shaking that cannot do its job. No amount of import surgery fixes that, and every number in this article assumes a single copy. Deduplicate first, then measure again.

Fix your icon imports first

The mixed variant isolates the culprit. Keeping path imports for all 12 components but reverting just the six icons to a barrel gives 676 ms and 8,466KB — most of the way back to the fully-barrelled number.

variantfirst loadtransferred
barrel everything764 ms 733–7669,775KB
path components, barrel icons676 ms 673–6808,466KB
path everything147 ms 142–1472,161KB

Icons account for roughly 83% of the barrel payload — (8,466 − 2,161) / (9,775 − 2,161), measuring the excess each variant carries over the path-import baseline. Six icons, against twelve components.

The reason is package shape. Vite pre-bundles each dependency as a unit, so one named import from @mui/icons-material — which ships a module per icon, thousands of them — pulls the whole set into the graph. A component barrel of a few hundred exports costs comparatively little.

// Slow — pulls the entire icon package into the dev graph
import { Delete, Edit, Save } from '@mui/icons-material';
// Fast — one module per icon
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import Save from '@mui/icons-material/Save';

If you change one thing, change the icons. Component path imports are worth doing for build time, but on their own they left 83% of the excess payload in place — the same bytes-based measure as above. (By elapsed time the figure is 86%: (676 − 147) / (764 − 147). Two measures, two numbers; we quote the byte one because it is the thing the barrel actually adds.)

When this is the wrong call. Path imports are verbose and easy to regress, so enforce them mechanically rather than by discipline:

// eslint.config.js
'no-restricted-imports': ['error', {
  paths: [{ name: '@mui/icons-material', message: 'Import icons individually: @mui/icons-material/Delete' }],
}],

And if your bundler already collapses this for you, the conversion buys nothing and costs readability. Next.js 13.5+ rewrites barrel imports at build time when you enable it:

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ['@mui/material', '@mui/icons-material'],
  },
};

We measured Vite, which has no equivalent. If you are on Next.js with that flag on, measure before converting anything — the fix may already be applied.

The sx prop costs far less than you have been told

A figure that circulates widely — it traces back to discussion around MUI's own performance notes and gets repeated without a source — is that 1,000 sx elements add ~200 ms over static styles. We rendered the same list three ways in a production build and timed the commit, median of 5 repeats:

rowssxstyled()plain CSS classsx − css
2501.5 ms0.5 ms0.1 ms1.4 ms
1,0004.2 ms1.6 ms0.4 ms3.8 ms
4,00012.5 ms4.4 ms0.6 ms11.9 ms

At 1,000 rows the sx penalty is 3.8 ms, not 200 ms — around fifty times smaller. It scales roughly linearly at 3–4 microseconds per row.

The ordering the advice gives is right: styled() is 2.6× faster than sx, and a plain CSS class 10.5× faster. What is wrong is the magnitude, and the magnitude is the part that tells you whether to care. A 4 ms commit is invisible; a 200 ms one would blow a frame budget several times over.

When to act on this anyway. Every first run in our samples was the slowest — 11.6 ms against a 4.2 ms median at 1,000 rows — because the style engine is cold. If your list renders once on a page nobody revisits, you pay that first-run cost, not the median. And an M5 is fast: a mid-range phone will be several times slower. It would need to be roughly 50× slower to reach the quoted figure, but "invisible" is a claim about this hardware, not all hardware.

So use sx freely for one-off styling. Reach for styled() when the same element repeats into the thousands — not because sx is slow, but because at 4,000 rows an 8 ms saving starts to be worth the indirection.

When these findings don't apply

The fixture isolates import style and styling API. Several real MUI slowdowns live outside that boundary and none of the above will touch them:

  • A second copy of MUI or Emotion. Duplicate versions in the tree double the runtime and defeat tree-shaking. Check with npm ls @mui/material @emotion/react before optimising anything else.
  • Heavy components in the initial chunk. DataGrid and the date pickers are large by design. Path imports do not remove them from the bundle; only React.lazy does, and that is a bundle fix rather than a dev-server one.
  • Next.js with optimizePackageImports enabled. As above — the barrel cost may already be gone, in which case our 5.2× does not describe your setup.
  • Warm dependency caches. Our first-load numbers clear node_modules/.vite each run. Day to day, with a warm cache, the gap is much smaller; the cost lands on fresh clones, lockfile changes, and CI.

Measure your own app in two minutes

Start your dev server, then run this against your entry point. It walks the module graph the way the browser does and reports what your bundler actually serves on first load:

// count-graph.mjs — node count-graph.mjs http://localhost:5173 /src/main.jsx
const [base, entry = "/src/main.jsx"] = process.argv.slice(2);
const seen = new Set(); const queue = [entry];
let bytes = 0;
const t0 = performance.now();
while (queue.length) {
  // Dedupe within the batch as well as against seen: the same URL can be
  // queued twice by two importers before either is marked visited.
  const batch = [...new Set(queue.splice(0, 8))].filter((u) => !seen.has(u));
  batch.forEach((u) => seen.add(u));
  const bodies = await Promise.all(batch.map(async (u) => {
    const res = await fetch(base + u);
    return (res.headers.get("content-type") || "").includes("javascript") ? res.text() : "";
  }));
  for (const code of bodies) {
    bytes += code.length;
    for (const m of code.matchAll(/(?:from\s*|^import\s*|import\(\s*)["']([^"']+)["']/gm))
      if (m[1].startsWith("/") && !m[1].startsWith("/@vite/") && !seen.has(m[1])) queue.push(m[1]);
  }
}
// Note: this sums decoded source length, not compressed bytes on the wire.
console.log(`${seen.size} modules, ${(bytes / 1024 / 1024).toFixed(1)}MB of source in ${Math.round(performance.now() - t0)}ms`);

Run it with a cold dependency cache (rm -rf node_modules/.vite first) and read the transferred size against these bands, which come from the measurements above:

Source served on first loadVerdict
Under 3MBHealthy for an MUI app this size. Look elsewhere.
3–6MBSomething is pulling in more than it needs. Check icons first.
Over 6MBAn icon barrel is almost certainly in the graph. Ours hit 9.8MB.

If that number is high but your production bundle is fine, that is the expected shape: this is a dev-server and build-time problem, not something your users experience.

What to do, in measured order

  1. Convert icon imports to paths — 83% of the barrel payload, and the only change that moved first load substantially. Enforce with no-restricted-imports.
  2. Convert component imports — worth 3.5× on build time; near-invisible on first load once icons are fixed.
  3. Check for duplicate MUI or Emotion copies — outside this experiment's scope, but it defeats everything else when present.
  4. Leave sx alone unless you are rendering thousands of identical elements, in which case styled() is measurably better.
  5. Do not convert imports to shrink your bundle. It will not. Lazy-load the heavy components instead.

Methodology and raw data: the fixture generator, both benchmark scripts, and the full results — including every run, not just medians — are in the evidence folder.

Related guides:

D
Declan

Software engineer in Melbourne. Writes guides at PageSpeedFix and builds web performance tools. More at dekk.dev.