Your MUI app worked great in development. Then it hit production: 300kb+ bundles, 3-second load times, and PageSpeed scores in the red.
Material UI is the most popular React component library—and one of the easiest to accidentally make slow. The good news: most MUI performance problems come from three fixable patterns. This guide shows you exactly how to fix them and recover your Core Web Vitals.
The three things slowing down your MUI app
Every slow MUI app I've debugged had one (or more) of these problems:
- Bundle size - The full library is 300kb+ gzipped. Icons alone can add another 50kb+ if imported wrong.
- Dev server speed - Barrel imports cause your bundler to process thousands of files on every change.
- Runtime performance - Emotion (the CSS-in-JS engine) computes styles at runtime, and the
sxprop adds overhead.
Let's fix each one.
Fix your imports first (biggest win)
This single change can cut dev server startup by 60% and shave 100kb+ off your bundle. How you import MUI components affects everything.
The problem with barrel imports
// Slow - processes entire MUI library
import { Button, TextField, Card } from '@mui/material';
import { Delete, Edit, Save } from '@mui/icons-material';
When you import from @mui/material, your bundler has to process the barrel file that re-exports every component. During development, this means slower startup and hot module replacement.
For @mui/icons-material, it's worse - named imports can be 6x slower than path imports because there are thousands of icons.
Use path imports instead
// Fast - only processes what you need
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Card from '@mui/material/Card';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import Save from '@mui/icons-material/Save';
Yes, it's more verbose. But your dev server will thank you.
Framework-specific fixes
Next.js 13.5+ handles this automatically with optimizePackageImports:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['@mui/material', '@mui/icons-material'],
},
};
Vite users should still use path imports - there's no automatic optimization yet. See our Vite + React performance guide for more Vite-specific optimizations.
Lazy load heavy components
Some MUI components are massive. Don't load them until needed.
DataGrid
The DataGrid alone is 90kb+ gzipped. If it's not visible on initial load, lazy load it:
import { lazy, Suspense } from 'react';
const DataGrid = lazy(() => import('@mui/x-data-grid').then(mod => ({ default: mod.DataGrid })));
function AdminDashboard() {
return (
<Suspense fallback={<TableSkeleton />}>
<DataGrid rows={rows} columns={columns} />
</Suspense>
);
}
DatePicker and other pickers
Same approach for date/time pickers:
const DatePicker = lazy(() => import('@mui/x-date-pickers/DatePicker').then(mod => ({ default: mod.DatePicker })));
function BookingForm() {
const [showPicker, setShowPicker] = useState(false);
return (
<div>
<Button onClick={() => setShowPicker(true)}>Select Date</Button>
{showPicker && (
<Suspense fallback={<Skeleton width={200} height={40} />}>
<DatePicker />
</Suspense>
)}
</div>
);
}
This directly improves LCP by reducing initial JavaScript.
Optimize runtime performance
MUI uses Emotion for CSS-in-JS. Styles are computed at runtime, which has overhead.
sx prop performance
The sx prop is convenient but not free. For a single component, the overhead is negligible. But it adds up.
MUI's own benchmark: Rendering 1000 elements with sx adds ~200ms compared to static styles. That's 0.2ms per component - small individually, but significant for lists.
// Slower - sx computed for each item
{items.map(item => (
<Box key={item.id} sx={{ p: 2, mb: 1, bgcolor: 'grey.100' }}>
{item.name}
</Box>
))}
// Faster - styles computed once
const ItemBox = styled(Box)({
padding: 16,
marginBottom: 8,
backgroundColor: '#f5f5f5',
});
{items.map(item => (
<ItemBox key={item.id}>{item.name}</ItemBox>
))}
Rule of thumb: Use sx for one-off styling. Use styled() for repeated elements or reusable components.
Avoid dynamic sx values in loops
// Bad - creates new style object every render, for every item
{items.map(item => (
<Box sx={{ color: item.isActive ? 'primary.main' : 'text.secondary' }}>
{item.name}
</Box>
))}
// Better - conditional className
const activeClass = { color: 'primary.main' };
const inactiveClass = { color: 'text.secondary' };
{items.map(item => (
<Box sx={item.isActive ? activeClass : inactiveClass}>
{item.name}
</Box>
))}
// Best - use CSS classes for truly large lists
Theme access in sx
Callback functions in sx are invoked on every render:
// Invoked every render
<Box sx={(theme) => ({ color: theme.palette.primary.main })} />
// Static - resolved once
<Box sx={{ color: 'primary.main' }} />
Use the shorthand string syntax ('primary.main') when possible.
Emotion cache setup
MUI uses Emotion for CSS-in-JS, and the order styles are injected into the DOM matters. If you're seeing style conflicts or your custom styles aren't overriding MUI defaults, your Emotion cache might be misconfigured.
Why cache order matters
By default, Emotion appends styles to the end of <head>. MUI expects its styles to load first so your custom styles can override them. Without proper configuration, you end up with specificity battles:
/* MUI styles (loaded later, wins) */
.MuiButton-root { padding: 6px 16px; }
/* Your styles (loaded earlier, loses) */
.MuiButton-root { padding: 12px 24px; }
The fix
Create an Emotion cache with prepend: true:
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
const muiCache = createCache({
key: 'mui',
prepend: true, // Injects MUI styles at the START of <head>
});
function App() {
return (
<CacheProvider value={muiCache}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</CacheProvider>
);
}
Now MUI styles load first, and your styles (loaded later) naturally override them without !important hacks.
Next.js App Router setup
For Next.js with the App Router, you need additional configuration to prevent hydration mismatches:
// app/providers.tsx
'use client';
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';
export function EmotionCacheProvider({ children }) {
const [cache] = useState(() => {
const cache = createCache({ key: 'mui', prepend: true });
cache.compat = true;
return cache;
});
useServerInsertedHTML(() => {
return (
<style
data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
dangerouslySetInnerHTML={{
__html: Object.values(cache.inserted).join(' '),
}}
/>
);
});
return <CacheProvider value={cache}>{children}</CacheProvider>;
}
This ensures styles are properly serialized during SSR and hydrated on the client without flicker.
MUI and Core Web Vitals
MUI can help or hurt your Core Web Vitals depending on how you use it. Here's how each metric is affected and specific fixes.
LCP impact
MUI affects LCP primarily through JavaScript bundle size. The browser can't render your page until it downloads, parses, and executes your JS. A bloated MUI bundle delays everything.
How MUI hurts LCP:
- Large initial bundle blocks rendering (300kb+ if imported wrong)
- Heavy components like DataGrid loaded eagerly
- Emotion style computation delays first paint
Fix it:
- Lazy load below-fold components:
const DataGrid = lazy(() => import('@mui/x-data-grid').then(m => ({ default: m.DataGrid })));
const DatePicker = lazy(() => import('@mui/x-date-pickers/DatePicker').then(m => ({ default: m.DatePicker })));
- Use path imports to enable better tree-shaking:
// Each component is a separate chunk that can be split
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
- Server-side render critical content - MUI fully supports SSR. Your hero content renders as HTML before JS loads:
// This HTML is visible immediately, even before hydration
<Typography variant="h1">Welcome</Typography>
- Preload critical MUI chunks if you know they're needed:
<link rel="modulepreload" href="/_next/static/chunks/mui-core.js" />
CLS impact
MUI components have built-in dimensions, so CLS is usually not a major problem. But there are gotchas:
Common CLS causes with MUI:
- Skeleton loaders that don't match final size:
// Bad - skeleton is 40px, actual button is 36px
<Skeleton width={100} height={40} />
<Button>Submit</Button>
// Good - match dimensions exactly
<Skeleton width={64} height={36} variant="rectangular" />
<Button>Submit</Button>
- Icons loading after text:
// Bad - button width changes when icon loads
<Button startIcon={<SaveIcon />}>Save</Button>
// Good - reserve space with minWidth
<Button startIcon={<SaveIcon />} sx={{ minWidth: 100 }}>
Save
</Button>
- Dynamic content in Dialogs/Drawers:
// Bad - dialog height jumps when content loads
<Dialog open={open}>
<DialogContent>
{loading ? <CircularProgress /> : <LongContent />}
</DialogContent>
</Dialog>
// Good - set minimum height
<Dialog open={open}>
<DialogContent sx={{ minHeight: 300 }}>
{loading ? <CircularProgress /> : <LongContent />}
</DialogContent>
</Dialog>
- Async Autocomplete options:
// Reserve space for dropdown
<Autocomplete
loading={loading}
options={options}
ListboxProps={{ style: { maxHeight: 300 } }}
/>
INP impact
The sx prop's runtime overhead can affect INP if you're re-rendering many components on user interaction. Every keystroke or click that triggers a re-render pays the Emotion computation cost.
How MUI hurts INP:
sxprop recalculates styles on every render- Large component trees re-render on state changes
- Unoptimized event handlers block the main thread
Fix it:
- Use
styled()for components that re-render frequently:
// Bad - sx recalculates on every keystroke
function SearchResults({ query, results }) {
return results.map(r => (
<Box key={r.id} sx={{ p: 2, borderBottom: 1 }}>{r.name}</Box>
));
}
// Good - styles computed once
const ResultItem = styled(Box)({
padding: 16,
borderBottom: '1px solid',
});
function SearchResults({ query, results }) {
return results.map(r => <ResultItem key={r.id}>{r.name}</ResultItem>);
}
- Memoize expensive component trees:
const MemoizedDataGrid = memo(DataGrid);
// Only re-renders when rows or columns actually change
<MemoizedDataGrid rows={rows} columns={columns} />
- Debounce rapid interactions:
const debouncedSearch = useMemo(
() => debounce((query) => setSearchQuery(query), 150),
[]
);
<TextField onChange={(e) => debouncedSearch(e.target.value)} />
- Move heavy work out of render:
// Bad - filtering happens on every render
function FilteredList({ items, filter }) {
const filtered = items.filter(i => i.name.includes(filter)); // Expensive!
return filtered.map(i => <ListItem key={i.id}>{i.name}</ListItem>);
}
// Good - memoize expensive computation
function FilteredList({ items, filter }) {
const filtered = useMemo(
() => items.filter(i => i.name.includes(filter)),
[items, filter]
);
return filtered.map(i => <ListItem key={i.id}>{i.name}</ListItem>);
}
Analyze your bundle
Before optimizing, measure. Guessing at performance problems wastes time. Bundle analysis shows exactly where your bytes come from.
webpack-bundle-analyzer
For Create React App or custom Webpack setups:
npm install --save-dev webpack-bundle-analyzer
Add to your webpack config or use the standalone script:
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: true,
}),
],
};
Vite users: rollup-plugin-visualizer
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
open: true,
gzipSize: true,
filename: 'bundle-stats.html',
}),
],
};
Run npm run build and a treemap visualization opens automatically.
What to look for
1. Multiple copies of MUI packages:
You might see @mui/material appearing multiple times if different dependencies pull in different versions. Fix with npm/yarn resolutions:
// package.json
{
"resolutions": {
"@mui/material": "5.15.0"
}
}
2. The entire icons package:
If you see @mui/icons-material taking 50kb+ but only use 5 icons, you're importing wrong. Each icon should be ~1kb.
3. Heavy components in the main chunk: DataGrid, DatePicker, or Charts appearing in your initial bundle means they're not lazy loaded. These should be in separate chunks.
4. Emotion duplicates:
Multiple versions of @emotion/react or @emotion/styled bloat your bundle and can cause style conflicts.
Interpreting the treemap
The treemap shows rectangles sized by file size. Look for:
- Large rectangles = optimization opportunities
- Unexpected packages = accidental dependencies
- node_modules dominating = too many dependencies or wrong imports
A healthy MUI app should show:
@mui/material: 80-150kb gzipped (depending on components used)@mui/icons-material: 5-20kb gzipped (only icons you use)@emotion/*: 10-15kb gzipped total- Your app code: varies, but should be smaller than dependencies
Quick wins checklist
Bundle size
- Use path imports:
import Button from '@mui/material/Button' - Use path imports for icons:
import Delete from '@mui/icons-material/Delete' - Lazy load DataGrid, DatePicker, and other heavy components
- Run bundle analyzer to find bloat
Dev server speed
- Switch all barrel imports to path imports
- Use Next.js
optimizePackageImportsif available - Check for duplicate MUI versions in node_modules
Runtime performance
- Use
styled()instead ofsxfor repeated elements - Avoid dynamic sx values in loops
- Use string shortcuts (
'primary.main') instead of theme callbacks - Set up Emotion cache with
prepend: true
Frequently Asked Questions
Is MUI too heavy for performance-critical apps?
No, but you need to be intentional. Lazy load what you don't need immediately, use path imports, and avoid sx prop abuse. Many production apps serve millions of users with MUI.
Should I switch to a different component library?
Only if MUI's specific features aren't worth the bundle cost for your use case. Headless libraries like Radix or Headless UI are smaller but require you to style everything yourself. MUI's value is in the complete, accessible components.
Does tree-shaking work with MUI?
Yes, in production builds. But tree-shaking doesn't help dev server speed - that's why path imports matter for DX even though the production bundle would be the same.
Can I use MUI with React Server Components?
MUI components need client-side JavaScript. In Next.js App Router, mark them with 'use client'. You can still use Server Components for layout and data fetching, then render MUI components on the client.
What's next
Most MUI performance problems disappear with two changes: fix your imports and lazy load heavy components. Do these before anything else.
Your action plan:
- Search your codebase for
from '@mui/material'andfrom '@mui/icons-material' - Convert to path imports
- Lazy load DataGrid, DatePicker, and anything not visible on initial load
- Run bundle analyzer to verify the results
Want to pinpoint exactly what's slowing down your MUI app? Run your site through PageSpeedFix to identify the specific issues hurting your Core Web Vitals.